circular_image_marker.js 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. // Register a circular photo marker as a Mapbox GL JS map image.
  2. //
  3. // Why a canvas + ImageBitmap (not a DOM mapboxgl.Marker, not ctx.clip()):
  4. // - addImage symbol layers batch/cluster/GPU-draw — DOM markers don't.
  5. // - createImageBitmap() yields PREMULTIPLIED alpha → no white fringe at the AA edge
  6. // (a raw HTMLImageElement/ImageData fringes a halo).
  7. // - the disc is masked with destination-in (an anti-aliased arc FILL) on a scratch
  8. // canvas, NOT ctx.clip() (clip is a 1-bit hard mask → jagged circle).
  9. //
  10. // Namespace the name ("rcphoto-…") so it can't collide with a basemap sprite icon
  11. // (an un-namespaced hasImage() can return true for the style's sprite and silently
  12. // drop your image).
  13. //
  14. // Same-origin: the photo must be same-origin (or CORS-enabled) or the canvas taints
  15. // and createImageBitmap throws.
  16. //
  17. // Usage:
  18. // await addCircularPhotoMarker(map, "rcphoto-falls", "photos/thumbs/falls.jpg");
  19. // map.addLayer({ id:"photo-pts", type:"symbol", source:"pts",
  20. // layout:{ "icon-image":"rcphoto-falls", "icon-anchor":"bottom",
  21. // "icon-offset":[0,8], // tip on the point (8 units of pad below tip)
  22. // "icon-size":["interpolate",["linear"],["zoom"], 11,0.82, 14,1.23, 17,1.5],
  23. // "icon-allow-overlap":true } });
  24. function addCircularPhotoMarker(map, name, photoUrl, opts = {}) {
  25. const {
  26. frameColor = "#355e3b", // ring colour
  27. F = 6, // supersample factor (high DPI; never let icon-size > native)
  28. boxW = 44, boxH = 52, // logical box; tip at (boxW/2, 44), 8 units of pad below
  29. cy = 21, rOut = 16.5, rPhoto = 13.1,
  30. } = opts;
  31. return new Promise((resolve) => {
  32. if (map.hasImage(name)) return resolve(name);
  33. const img = new Image();
  34. img.crossOrigin = "anonymous"; // allow CORS-served images
  35. img.onerror = () => resolve(null);
  36. img.onload = async () => {
  37. if (map.hasImage(name)) return resolve(name);
  38. const W = boxW * F, H = boxH * F;
  39. const c = document.createElement("canvas"); c.width = W; c.height = H;
  40. const ctx = c.getContext("2d");
  41. ctx.scale(F, F); // draw in logical (44×52) space
  42. ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high";
  43. const cx = boxW / 2, tipY = 44;
  44. // (1) faint contact shadow under the tip so the bubble reads as grounded
  45. ctx.save();
  46. ctx.translate(cx, tipY); ctx.scale(1, 0.36);
  47. const gnd = ctx.createRadialGradient(0, 0, 0, 0, 0, 13);
  48. gnd.addColorStop(0, "rgba(20,16,9,0.28)"); gnd.addColorStop(1, "rgba(20,16,9,0)");
  49. ctx.fillStyle = gnd; ctx.beginPath(); ctx.arc(0, 0, 13, 0, 2 * Math.PI); ctx.fill();
  50. ctx.restore();
  51. // bubble = circle + short downward spike to the tip (apex = the anchor point)
  52. const bubble = (r) => {
  53. const hb = r * 0.34;
  54. ctx.beginPath(); ctx.moveTo(cx - hb, cy); ctx.lineTo(cx + hb, cy);
  55. ctx.lineTo(cx, tipY); ctx.closePath(); ctx.fill();
  56. ctx.beginPath(); ctx.arc(cx, cy, r, 0, 2 * Math.PI); ctx.fill();
  57. };
  58. // (2) drop shadow on the frame body for lift (cleared before drawing the photo)
  59. ctx.save();
  60. ctx.shadowColor = "rgba(20,16,9,0.26)"; ctx.shadowBlur = 2.8; ctx.shadowOffsetY = 0.9;
  61. ctx.fillStyle = frameColor; bubble(rOut);
  62. ctx.restore();
  63. // (3) subtle top-lit sheen on the frame (AA arc fill)
  64. const sheen = ctx.createLinearGradient(0, cy - rOut, 0, cy + 2);
  65. sheen.addColorStop(0, "rgba(255,255,255,0.26)"); sheen.addColorStop(1, "rgba(255,255,255,0)");
  66. ctx.fillStyle = sheen; ctx.beginPath(); ctx.arc(cx, cy, rOut, 0, 2 * Math.PI); ctx.fill();
  67. // (4) cover-fit + AA-mask the photo into the inner disc via destination-in
  68. const pr = rPhoto * F;
  69. const sc = document.createElement("canvas"); sc.width = sc.height = pr * 2;
  70. const sx = sc.getContext("2d");
  71. sx.imageSmoothingEnabled = true; sx.imageSmoothingQuality = "high";
  72. const iw = img.naturalWidth || 1, ih = img.naturalHeight || 1;
  73. const scale = Math.max((pr * 2) / iw, (pr * 2) / ih);
  74. sx.drawImage(img, pr - (iw * scale) / 2, pr - (ih * scale) / 2, iw * scale, ih * scale);
  75. sx.globalCompositeOperation = "destination-in"; // AA circular mask (NOT clip())
  76. sx.beginPath(); sx.arc(pr, pr, pr, 0, 2 * Math.PI); sx.fill();
  77. ctx.drawImage(sc, cx - rPhoto, cy - rPhoto, rPhoto * 2, rPhoto * 2);
  78. // (5) register premultiplied (createImageBitmap) so the edge doesn't fringe
  79. try {
  80. const bmp = await createImageBitmap(c);
  81. if (!map.hasImage(name)) map.addImage(name, bmp, { pixelRatio: F });
  82. } catch (e) {
  83. // fallback: register the canvas directly (may fringe slightly)
  84. if (!map.hasImage(name)) map.addImage(name, c, { pixelRatio: F });
  85. }
  86. resolve(name);
  87. };
  88. img.src = photoUrl;
  89. });
  90. }
  91. if (typeof module !== "undefined" && module.exports) module.exports = { addCircularPhotoMarker };