@tomtom-org/maps-sdk-plugin-map-effects 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.es.js CHANGED
@@ -1,2 +1,2 @@
1
- import{StylingModule as e,parseCssColor as t,stylingColorKnobIds as a}from"@tomtom-org/maps-sdk/map";var s=e=>e.split(".").slice(0,-1).map((e,t,a)=>a.slice(0,t+1).join(".")),n=(()=>{const e=/* @__PURE__ */new Map;for(const t of a)for(const a of[...s(t),t])e.set(a,[...e.get(a)??[],t]);return e})(),r=[...n.keys()],i=e=>"string"!=typeof e?`${String(e)} is not a string`:n.has(e)||t(e)?void 0:`'${e}' is neither a CSS colour nor a styling colour knob or group`,o=(e,t,a)=>32*(32*(e>>3)+(t>>3))+(a>>3),h=([e,t,a])=>{const s=Math.max(e,t,a);if(0!==s)return[e/s,t/s,a/s]},l=(e,t)=>{const a=e.getImageData(0,0,e.canvas.width,e.canvas.height);((e,t)=>{for(let a=0;a<e.length;a+=4)t[o(e[a],e[a+1],e[a+2])]||(e[a]=0,e[a+1]=0,e[a+2]=0)})(a.data,t),e.putImageData(a,0,0)},c={min:0,max:1,step:.05},d="Emphasis on dark and driving styles.",u="Muting the base map under a data overlay.",f="Depth cue: the far context recedes as if seen through air.",g="Focus: the centre stays sharp and the edges lose their detail.",p="Depth on a tilted map: one band of distance stays sharp, the rest defocuses with distance.",m="Muting the base map under a data overlay; matching a brand.",b="Focus on the centre of the view.",w={"bloom.intensity":{kind:"number",description:"How strongly bright features (lit roads, traffic tubes, route lines) glow. 0 = off.",default:0,range:c,useCase:d},"bloom.radius":{kind:"number",description:"How far the glow spreads, in CSS pixels.",default:12,range:{min:2,max:40,step:1},useCase:d},"bloom.threshold":{kind:"number",description:"Brightness below which nothing glows. Raise it so only the brightest features light up.",default:.55,range:c,useCase:d},"bloom.only":{kind:"colors",description:"The colours bloom may light: CSS colours, styling colour knobs or groups of them (`traffic`, `traffic.incidents.majorColor`), read from the loaded style. Only the pixels wearing one of them glow; empty lights the whole frame.",default:[],options:r,useCase:d},"bloom.onlyTolerance":{kind:"number",description:"How far a pixel may sit from one of the scope's colours and still glow. Raise it when antialiasing has mixed the colour with the map underneath; lower it when a neighbouring severity is glowing too.",default:.15,range:c,useCase:d},"grade.brightness":{kind:"number",description:"Brightness of the whole base map (1 = unchanged).",default:1,range:{min:.5,max:1.5,step:.05},useCase:u},"grade.contrast":{kind:"number",description:"Contrast of the whole base map (1 = unchanged).",default:1,range:{min:.5,max:1.5,step:.05},useCase:u},"grade.saturation":{kind:"number",description:"Colour saturation of the whole base map (1 = unchanged, 0 = greyscale).",default:1,range:{min:0,max:1.5,step:.05},useCase:u},"fog.intensity":{kind:"number",description:"Haze over the edges of the view: blur, plus a wash towards the colour of the air — pale on a light map, dark on a dark one. Reads as distance. 0 = off.",default:0,range:c,useCase:f},"fog.reach":{kind:"number",description:"How far the fog reaches in from the edges (1 = to the centre).",default:.4,range:c,useCase:f},"edgeBlur.intensity":{kind:"number",description:"Blur over the edges of the view, in CSS pixels, leaving their colour untouched. Reads as a lens, not as distance — `fog` is the effect that also washes the rim out. 0 = off.",default:0,range:{min:0,max:30,step:1},useCase:g},"edgeBlur.reach":{kind:"number",description:"How far the edge blur reaches in from the edges (1 = to the centre).",default:.5,range:c,useCase:g},"depthOfField.intensity":{kind:"number",description:"The widest circle of confusion, in CSS pixels — the blur the most out-of-focus distance gets. Needs a tilted camera: a map seen from straight above has no depth, and the effect ends there on its own. 0 = off.",default:0,range:{min:0,max:30,step:1},useCase:p},"depthOfField.focus":{kind:"number",description:"Which distance is sharp, across what the frame shows: 0 the nearest ground at the bottom of the view, 1 the farthest at the top.",default:0,range:c,useCase:p},"depthOfField.band":{kind:"number",description:"How much of that depth stays sharp — the field itself. Thin is the miniature; wide holds a whole run of the map. A gentle tilt has little depth, so the same value covers more of the view.",default:.3,range:c,useCase:p},"depthOfField.bokeh":{kind:"number",description:"Whether an out-of-focus highlight fades or comes back as a disc of light. 0 averages the light away, as a blur does; turned up, a lit road or a traffic tube keeps its brightness across the whole circle it is spread over.",default:.5,range:c,useCase:p},"tint.color":{kind:"color",description:"A flat colour laid over the whole map.",default:"#1f2937",useCase:m},"tint.opacity":{kind:"number",description:"Opacity of the tint. 0 = off.",default:0,range:c,useCase:m},"vignette.intensity":{kind:"number",description:"Darkening (negative) or lightening (positive) towards the edges. 0 = off.",default:0,range:{min:-1,max:1,step:.05},useCase:b},"vignette.reach":{kind:"number",description:"How far the vignette reaches in from the edges (1 = to the centre).",default:.45,range:c,useCase:b}},v=Object.keys(w),y=v.map(e=>({id:e,...w[e]})),x=(e,t,a)=>Math.min(Math.max(e,t),a),E=e=>x(e,0,1),k=(e,t,a=1)=>{const s=.92*E(t),n=(1+s)/(1-s);return`brightness(${(1/(1+s)).toFixed(4)}) contrast(${n.toFixed(3)}) blur(${e*a}px) saturate(1.35)`},T=(e,t,a)=>[1===e?"":`brightness(${e})`,1===t?"":`contrast(${t})`,1===a?"":`saturate(${a})`].filter(Boolean).join(" "),S=(e,t,a)=>`radial-gradient(ellipse farthest-corner at center, ${e} ${Math.round(100*(1-E(a)))}%, ${t} 100%)`,C=(e,t)=>{const{inner:a,outer:s}=M(e);return S(a,s,t)},M=e=>{const t=e<0?"0, 0, 0":"255, 255, 255";return{inner:`rgba(${t}, 0)`,outer:`rgba(${t}, ${Math.min(Math.abs(e),1)})`}},R=e=>S("transparent","black",e),A=e=>e*Math.PI/180,L=(e,t,a)=>{const s=(({pitch:e,verticalFieldOfView:t})=>{const a=Math.tan(A(x(e,0,89))),s=2*Math.tan(A(x(t,1,175))/2)*a;return s/(1+s/2)})(t);if(!(s<=0||e.blurCssPx<=0))return{uDepth:s,uFocus:Math.min(E(e.focus)*s,1),uBand:.5*E(e.band),uBlur:Math.max(e.blurCssPx,0)*a.scale,uBokeh:E(e.bokeh),uTexel:[1/Math.max(a.width,1),1/Math.max(a.height,1)]}},B={light:{rgb:"238, 240, 244",grade:"brightness(1.08) contrast(0.82) saturate(0.7)"},dark:{rgb:"16, 22, 33",grade:"brightness(0.92) contrast(0.82) saturate(0.7)"}},P="saturate(1.08)",D=e=>B[e].grade,_=e=>2+6*e,F=(e,t)=>`blur(${_(e)}px) ${D(t)}`,$=(e,t)=>{const{rgb:a}=B[t];return{inner:`rgba(${a}, 0)`,outer:`rgba(${a}, ${.65*e})`}},U=(e,t,a)=>{const{inner:s,outer:n}=$(e,a);return S(s,n,t)},I=(e,t,a,s)=>{const n=document.createElement("canvas");n.width=t,n.height=a;const r=n.getContext("2d");if(r)return r.filter=s,r.drawImage(e.canvas,0,0),r.filter="none",r},O=(e,t,a,s=1)=>{e.save(),e.globalCompositeOperation=a,e.globalAlpha=s,e.drawImage(t.canvas,0,0),e.restore()},N=(e,t,a,s,n,r)=>{const i=t/Math.SQRT2,o=a/Math.SQRT2,h=e.createRadialGradient(0,0,1-s,0,0,1);h.addColorStop(0,n),h.addColorStop(1,r),e.save(),e.translate(t/2,a/2),e.scale(i,o),e.fillStyle=h,e.fillRect(-t/2/i,-a/2/o,t/i,a/o),e.restore()},G=(e,t,a,s,n,r,i)=>{const o=I(e,t,a,`blur(${n*s}px) ${r}`.trim());o&&(o.globalCompositeOperation="destination-in",i(o),e.drawImage(o.canvas,0,0))},W=(e,t,a,s)=>{const n=I(e,t,a,"none");return n&&l(n,s),n},X=(e,t,a,s,n,r,i)=>{n["bloom.intensity"]>0&&((e,t,a,s,n,r)=>{const i=k(n["bloom.radius"],n["bloom.threshold"],s),o=r?W(e,t,a,r):e;if(!o)return;const h=I(o,t,a,i);h&&O(e,h,"screen",n["bloom.intensity"])})(e,t,a,s,n,i);const o=T(n["grade.brightness"],n["grade.contrast"],n["grade.saturation"]);o&&((e,t,a,s)=>{const n=I(e,t,a,s);n&&O(e,n,"copy")})(e,t,a,o);const h=e=>s=>N(s,t,a,e,"rgba(0,0,0,0)","rgba(0,0,0,1)");if(n["fog.intensity"]>0){const i=n["fog.intensity"],o=n["fog.reach"],{inner:l,outer:c}=$(i,r),d=_(i);G(e,t,a,s,d,D(r),h(o)),N(e,t,a,o,l,c)}if(n["edgeBlur.intensity"]>0){const r=n["edgeBlur.intensity"];G(e,t,a,s,r,P,h(n["edgeBlur.reach"]))}if(n["tint.opacity"]>0&&(e.save(),e.globalAlpha=n["tint.opacity"],e.fillStyle=n["tint.color"],e.fillRect(0,0,t,a),e.restore()),0!==n["vignette.intensity"]){const{inner:s,outer:r}=M(n["vignette.intensity"]);N(e,t,a,n["vignette.reach"],s,r)}},j=e=>Object.fromEntries(v.map(t=>[t,e[t]??w[t].default])),K=e=>{e.texParameteri(e.TEXTURE_2D,e.TEXTURE_WRAP_S,e.CLAMP_TO_EDGE),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_WRAP_T,e.CLAMP_TO_EDGE),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_MAG_FILTER,e.LINEAR),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_MIN_FILTER,e.LINEAR)},q=(e,t,a)=>{e.width===t&&e.height===a||(e.width=t,e.height=a)},H=(e,t,a)=>{const s=e.createShader(t);if(!s)return"the context would not create a shader";if(e.shaderSource(s,a),e.compileShader(s),e.getShaderParameter(s,e.COMPILE_STATUS))return s;const n=e.getShaderInfoLog(s)??"";return e.deleteShader(s),`failed to compile: ${n}`},z=class{canvas;gl;programs=/* @__PURE__ */new Map;failures=/* @__PURE__ */new Set;sourceTexture;targets=[];targetSize={width:0,height:0};lost=!1;constructor(){this.canvas=document.createElement("canvas"),this.canvas.addEventListener("webglcontextlost",e=>{e.preventDefault(),this.lost=!0}),this.canvas.addEventListener("webglcontextrestored",()=>{this.programs.clear(),this.failures.clear(),this.sourceTexture=void 0,this.targets=[],this.targetSize={width:0,height:0},this.lost=!1}),this.gl=this.canvas.getContext("webgl2",{alpha:!0,antialias:!1,premultipliedAlpha:!1,preserveDrawingBuffer:!0})??void 0}render(e,t){const a=this.gl;if(!a||this.lost||!t.length||!e.width||!e.height)return;q(this.canvas,e.width,e.height);let s=this.uploadSource(e,t[0].mipmapped);if(!(!s||t.length>1&&!this.holdTargets(a))){for(let e=0;e<t.length;e++){const n=e===t.length-1?void 0:this.targets[e%this.targets.length];this.runStage(a,t[e],s,n),n&&(s=n.texture,t[e+1].mipmapped&&this.mipmap(a,s))}return this.canvas}}get hasContext(){return void 0!==this.gl}compileError(e){const t=this.gl;if(!t||this.lost)return;const a=this.cachedProgram(t,e);return"string"==typeof a?a:void 0}release(e){const t=this.programs.get(e);t&&this.gl?.deleteProgram(t),this.programs.delete(e),this.failures.delete(e)}remove(){this.gl?.getExtension("WEBGL_lose_context")?.loseContext()}holdTargets(e){const{width:t,height:a}=this.canvas;if(2===this.targets.length&&this.targetSize.width===t&&this.targetSize.height===a)return!0;for(this.releaseTargets(e);this.targets.length<2;){const s=e.createTexture(),n=e.createFramebuffer();if(!s||!n)return!1;e.bindTexture(e.TEXTURE_2D,s),e.texImage2D(e.TEXTURE_2D,0,e.RGBA,t,a,0,e.RGBA,e.UNSIGNED_BYTE,null),K(e),e.bindFramebuffer(e.FRAMEBUFFER,n),e.framebufferTexture2D(e.FRAMEBUFFER,e.COLOR_ATTACHMENT0,e.TEXTURE_2D,s,0),this.targets.push({texture:s,framebuffer:n})}return this.targetSize={width:t,height:a},!0}releaseTargets(e){for(const{texture:t,framebuffer:a}of this.targets)e.deleteTexture(t),e.deleteFramebuffer(a);this.targets=[]}mipmap(e,t){e.bindTexture(e.TEXTURE_2D,t),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_MIN_FILTER,e.LINEAR_MIPMAP_LINEAR),e.generateMipmap(e.TEXTURE_2D)}uploadSource(e,t=!1){const a=this.gl;if(a&&(this.sourceTexture??=a.createTexture()??void 0,this.sourceTexture))return a.bindTexture(a.TEXTURE_2D,this.sourceTexture),a.pixelStorei(a.UNPACK_FLIP_Y_WEBGL,!0),a.texImage2D(a.TEXTURE_2D,0,a.RGBA,a.RGBA,a.UNSIGNED_BYTE,e),K(a),t&&this.mipmap(a,this.sourceTexture),this.sourceTexture}runStage(e,t,a,s){const n=this.programOf(e,t.fragment);if(n){e.bindFramebuffer(e.FRAMEBUFFER,s?.framebuffer??null),e.viewport(0,0,this.canvas.width,this.canvas.height),e.useProgram(n),e.activeTexture(e.TEXTURE0),e.bindTexture(e.TEXTURE_2D,a),e.uniform1i(e.getUniformLocation(n,"uSource"),0);for(const[a,s]of Object.entries(t.uniforms)){const t=e.getUniformLocation(n,a);t&&("number"==typeof s?e.uniform1f(t,s):e.uniform2f(t,s[0],s[1]))}e.drawArrays(e.TRIANGLES,0,3)}}programOf(e,t){if(this.failures.has(t))return;const a=this.cachedProgram(e,t);if("string"!=typeof a)return a;this.failures.add(t),console.warn(`[MapEffects] shader ${a}`)}cachedProgram(e,t){const a=this.programs.get(t);if(a)return a;const s=this.build(e,t);return"string"!=typeof s&&this.programs.set(t,s),s}build(e,t){const a=[H(e,e.VERTEX_SHADER,"#version 300 es\n// ⚠️ The directive above has to be the literal first line — ANGLE rejects even a comment before it,\n// and the whole program then fails to compile.\n//\n// The vertex half of every pass the substrate runs — see ../screenPass.ts, which loads this file\n// with Vite's `?raw`.\n//\n// It belongs to the substrate rather than to any one pass, because what it interprets is the\n// substrate's own geometry: a single full-screen triangle. One primitive rather than a quad's two,\n// so no diagonal seam is rasterised twice, and the corners fall out of `gl_VertexID` so there is no\n// buffer to bind.\n//\n// ⚠️ `vUv` runs with v increasing upward, matching the flipped upload in `uploadSource` — so v = 0\n// is the bottom of the frame, which on a tilted map is the nearest ground. The depth of field\n// depends on that, so it is a contract rather than an accident.\n\nout vec2 vUv;\n\nvoid main() {\n vec2 corner = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n vUv = corner;\n gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);\n}\n"),H(e,e.FRAGMENT_SHADER,`#version 300 es\n// ⚠️ The directive above has to be the literal first line — ANGLE rejects even a comment before it —\n// which is also why it lives here rather than in a stage: a stage is appended to this file, and a\n// second directive anywhere is a compile error. ../../tests/effectFilters.test.ts holds that.\n//\n// Not a program: the preamble ../screenPass.ts prepends to every fragment stage it compiles. It\n// carries what is the same for all of them — the source texture, the varying it is read at, the\n// colour that is written — so a pass declares only what is its own, and the substrate stays the one\n// place those names are spelled.\n//\n// GLSL ES 3.00, for the two things ES 1.00 cannot give a gather: \`textureLod\`, without which a tap\n// cannot read a prefiltered mip level, and a loop it may leave early.\n\nprecision highp float;\nprecision highp sampler2D;\n\nuniform sampler2D uSource;\nin vec2 vUv;\nout vec4 fragColor;\n${t}`)],s=a.find(e=>"string"==typeof e),n=a.filter(e=>"string"!=typeof e),r=s?null:e.createProgram();if(r){for(const t of n)e.attachShader(r,t);e.linkProgram(r)}for(const o of n)e.deleteShader(o);if(s)return s;if(!r)return"the context would not create a program";if(e.getProgramParameter(r,e.LINK_STATUS))return r;const i=e.getProgramInfoLog(r)??"";return e.deleteProgram(r),`failed to link: ${i}`}},V={"before-depth-of-field":!0,"after-depth-of-field":!0},Y=["bloom","grade","fog","edgeBlur","tint","vignette"],Q=e=>({backdropFilter:e,webkitBackdropFilter:e}),J=e=>({maskImage:e,webkitMaskImage:e}),Z={position:"absolute",inset:"0",width:"100%",height:"100%",pointerEvents:"none",display:"none"},ee=e=>e.width/Math.max(e.clientWidth,1),te=e=>{let t;return{schedule:()=>{void 0===t&&(t=requestAnimationFrame(()=>{t=void 0,e()}))},cancel:()=>{void 0!==t&&cancelAnimationFrame(t),t=void 0}}},ae=class{map;settings={};passes=[];container;overlays;bloomCanvas;artworkCanvas;chain=new z;chainDraw=te(()=>this.drawChain());bloomDraw=te(()=>this.drawBloom());styling;stylingRequest;scopeMask;scopeCanvas;warnedBuffer=!1;warnedScope=!1;warnedNoWebGL2=!1;warnedUniforms=/* @__PURE__ */new WeakSet;onStyleData=()=>this.dropScopeMask();onRender=()=>{this.chainDraw.schedule(),this.scheduleBloom()};constructor(e,t={}){this.map=e;const a=this.map.mapLibreMap.getContainer();this.container=document.createElement("div"),this.container.className="tomtom-map-effects",Object.assign(this.container.style,{position:"absolute",inset:"0",pointerEvents:"none",overflow:"hidden"}),a.querySelector(".maplibregl-canvas-container")?.insertAdjacentElement("afterend",this.container)??a.appendChild(this.container),this.bloomCanvas=document.createElement("canvas"),this.overlays=Object.fromEntries(Y.map(e=>{const t="bloom"===e?this.bloomCanvas:document.createElement("div");return t.dataset.effect=e,Object.assign(t.style,Z),this.container.appendChild(t),[e,t]})),this.bloomCanvas.style.mixBlendMode="screen",this.artworkCanvas=this.chain.canvas,this.artworkCanvas.dataset.effect="artwork",Object.assign(this.artworkCanvas.style,Z),this.container.insertAdjacentElement("afterbegin",this.artworkCanvas),this.map.mapLibreMap.on("render",this.onRender),this.map.mapLibreMap.on("styledata",this.onStyleData),this.set(t)}cameraAngles(){const e=this.map.mapLibreMap;return{pitch:e.getPitch(),verticalFieldOfView:e.getVerticalFieldOfView()}}set(e){for(const[t,a]of Object.entries(e))this.validate(t,a);this.settings={...this.settings,...e},this.render()}get(e){return this.assertKnob(e),this.settings[e]??w[e].default}reset(e){if(void 0===e)this.settings={};else{this.assertKnob(e);const{[e]:t,...a}=this.settings;this.settings=a}this.render()}getConfig(){return{...this.settings}}applyConfig(e){this.settings={},this.set(e)}addPass(e){if(!e.id)throw new Error("A pass needs an id, to be replaced or removed by.");if(void 0!==e.placement&&!Object.hasOwn(V,e.placement))throw new RangeError(`Pass '${e.id}' expects a placement of ${Object.keys(V).join(" or ")}; got ${String(e.placement)}.`);this.chain.hasContext||this.warnNoWebGL2();const t=this.chain.compileError(e.fragment);if(t)throw new Error(`Pass '${e.id}' ${t}`);const a={...e},s=this.passes.find(t=>t.id===e.id);this.passes=s?this.passes.map(e=>e===s?a:e):[...this.passes,a],s&&this.releaseUnused(s.fragment),this.warnIfBufferNotPreserved(),this.render()}removePass(e){const t=this.passes.find(t=>t.id===e);t&&(this.passes=this.passes.filter(e=>e!==t),this.releaseUnused(t.fragment),this.render())}async capture(e={}){this.warnIfBufferNotPreserved();const t=this.map.mapLibreMap,a=t,s=t.getPixelRatio(),n=e.pixelRatio??s,r=s!==(globalThis.devicePixelRatio||1),i=Math.abs(n-s)>.01;i&&(a.setPixelRatio(n),await this.settled());try{return await this.readBack()}finally{i&&a.setPixelRatio(r?s:null)}}remove(){this.map.mapLibreMap.off("render",this.onRender),this.map.mapLibreMap.off("styledata",this.onStyleData),this.bloomDraw.cancel(),this.chainDraw.cancel(),this.chain.remove(),this.container.remove()}render(){const e=j(this.settings),t=(e,t,a={})=>{const s=this.overlays[e];s.style.display=t?"block":"none",Object.assign(s.style,a)},a=T(e["grade.brightness"],e["grade.contrast"],e["grade.saturation"]);t("grade",""!==a,Q(a));const s=this.map.styleLightDarkTheme;var n;t("fog",e["fog.intensity"]>0,{...Q(F(e["fog.intensity"],s)),...J(R(e["fog.reach"])),backgroundImage:U(e["fog.intensity"],e["fog.reach"],s)}),t("edgeBlur",e["edgeBlur.intensity"]>0,{...Q((n=e["edgeBlur.intensity"],`blur(${n}px) ${P}`)),...J(R(e["edgeBlur.reach"]))}),e["depthOfField.intensity"]>0&&this.warnIfBufferNotPreserved(),this.drawChain(e),t("tint",e["tint.opacity"]>0,{backgroundColor:e["tint.color"],opacity:String(e["tint.opacity"])}),t("vignette",0!==e["vignette.intensity"],{backgroundImage:C(e["vignette.intensity"],e["vignette.reach"])});const r=e["bloom.intensity"]>0;t("bloom",r,{opacity:String(e["bloom.intensity"])}),r&&(this.warnIfBufferNotPreserved(),this.scheduleBloom())}chainStages(e){return[...this.passStages("before-depth-of-field"),...this.depthOfFieldStages(e),...this.passStages("after-depth-of-field")]}passStages(e){return this.passes.filter(t=>(t.placement??"before-depth-of-field")===e).flatMap(e=>{const t=this.uniformsOf(e);return t?[{fragment:e.fragment,uniforms:t}]:[]})}uniformsOf(e){const{uniforms:t={}}=e;if("function"!=typeof t)return t;try{return t()}catch(a){return void(this.warnedUniforms.has(e)||(this.warnedUniforms.add(e),console.warn(`[MapEffects] pass '${e.id}' is skipped while its uniforms() throws:`,a)))}}releaseUnused(e){this.passes.some(t=>t.fragment===e)||this.chain.release(e)}depthOfFieldStages(e){const t=this.map.mapLibreMap.getCanvas(),a=L({focus:e["depthOfField.focus"],band:e["depthOfField.band"],blurCssPx:e["depthOfField.intensity"],bokeh:e["depthOfField.bokeh"]},this.cameraAngles(),{width:t.width,height:t.height,scale:ee(t)});return a?[{fragment:"// The depth-of-field pass: the circle of confusion a tilted camera implies at each row of the\n// picture, and the aperture gathered over it. See ../defocusShader.ts, which loads this file and\n// names the uniforms below, and `defocusUniforms` in ../../effectFilters.ts, which works out the\n// optics this only applies.\n//\n// The declarations the substrate already made — `uSource`, `vUv`, `fragColor`, the precisions and\n// the version directive — are in ./fragmentPreamble.glsl and must not be repeated here.\n\n// What each of these means is on `DefocusUniforms` in ../defocusShader.ts, which is also where the\n// spelling is held to match.\nuniform float uDepth;\nuniform float uFocus;\nuniform float uBand;\nuniform float uBlur;\nuniform float uBokeh;\nuniform vec2 uTexel;\n\n// A tap budget rather than a tap count: the loop is bounded by a constant so a wide disc stays\n// affordable, and filled to `taps` so a narrow one stays smooth.\nconst int MAX_TAPS = 48;\n// The golden angle: successive taps land as far from every previous one as a turn can put them, so\n// a partly filled disc is still an even disc.\nconst float GOLDEN_ANGLE = 2.39996323;\n\n// Where a row falls on the depth scale: 0 at the nearest ground, 1 at the horizon. Linear in the\n// row because reciprocal distance across a plane really is affine in screen space.\nfloat depthAt(float v) {\n return uDepth * v;\n}\n\n/**\n * The circle of confusion at a row, in device pixels. Proportional to the distance from the plane\n * of focus in reciprocal units, which is what a thin lens does. The sharp band is taken out of the\n * ramp and the remainder stretched back over the full range, so widening the field moves where the\n * blur starts without also weakening how far it goes.\n */\nfloat circleOfConfusion(float v) {\n float defocus = abs(depthAt(v) - uFocus) - uBand;\n return uBlur * clamp(defocus / max(1.0 - uBand, 0.001), 0.0, 1.0);\n}\n\n// Rec. 709 luma.\nfloat luma(vec3 colour) {\n return dot(colour, vec3(0.2126, 0.7152, 0.0722));\n}\n\n/**\n * What makes it a bokeh rather than a blur: a lens does not average the light it cannot resolve, it\n * spreads it, so every point arrives as an image of the aperture at its own brightness. Weighting a\n * sample by the fourth power of its brightness keeps a highlight's value across the whole disc it\n * is spread over — at half brightness the extra weight is a sixteenth, so ordinary map colour still\n * averages as it should. Deliberately not energy-conserving: normalising that away leaves a blur.\n */\nfloat bokehWeight(vec3 colour) {\n float bright = luma(colour);\n float fourth = bright * bright * bright * bright;\n return 1.0 + uBokeh * 8.0 * fourth;\n}\n\n// Per-pixel and stable, so a still picture is still: the angle the disc below is spun by.\nfloat hash(vec2 at) {\n vec3 spread = fract(vec3(at.xyx) * 0.1031);\n spread += dot(spread, spread.yzx + 33.33);\n return fract((spread.x + spread.y) * spread.z);\n}\n\n/**\n * The aperture, gathered: a disc of `coc` diameter, weighted so the highlights survive it.\n *\n * Two dozen taps across a wide disc is a scatter of ghosts rather than a blur, and the fix is not\n * more taps but bigger ones — each is read from the mip level whose texels are about as wide as the\n * gaps between them, so what is sampled is already the average of what was skipped. The two numbers\n * are tied to each other rather than tuned apart, which is what keeps a wide disc the same picture\n * as a narrow one instead of a coarser one.\n */\nvec4 defocus(vec2 uv, float coc) {\n float radius = coc * 0.5;\n // Inside a texel there is nothing to gather, and the sharp band all comes through here — it has\n // to hand back the source unchanged.\n if (radius < 0.75) return textureLod(uSource, uv, 0.0);\n\n int taps = int(clamp(radius * 1.5, 12.0, float(MAX_TAPS)));\n float spacing = radius * sqrt(3.14159265 / float(taps));\n float level = max(log2(spacing), 0.0);\n // Without the spin every pixel samples the same directions and the residual undersampling lines\n // up into streaks; broken up, what is left reads as grain.\n float spin = hash(gl_FragCoord.xy) * 6.28318531;\n\n vec4 centre = textureLod(uSource, uv, level);\n float weight = bokehWeight(centre.rgb);\n vec4 total = vec4(centre.rgb * centre.a, centre.a) * weight;\n float carried = weight;\n\n for (int index = 0; index < MAX_TAPS; index++) {\n if (index >= taps) break;\n\n // The square root of the fraction, not the fraction: a disc's area grows with the square of\n // its radius, so evenly spaced radii would crowd every tap into the middle.\n float fraction = (float(index) + 0.5) / float(taps);\n float distance = radius * sqrt(fraction);\n float angle = float(index) * GOLDEN_ANGLE + spin;\n vec2 offset = vec2(cos(angle), sin(angle)) * distance;\n\n vec4 sampled = textureLod(uSource, uv + offset * uTexel, level);\n float tapWeight = bokehWeight(sampled.rgb);\n total += vec4(sampled.rgb * sampled.a, sampled.a) * tapWeight;\n carried += tapWeight;\n }\n\n // Colour comes back out of the premultiplied sum by its own alpha, the alpha by the weight.\n return vec4(total.rgb / max(total.a, 0.0001), total.a / carried);\n}\n\nvoid main() {\n // The texture is uploaded flipped, so this row runs from the bottom of the frame — the nearest\n // ground — which is the direction the depth ramp is measured in.\n fragColor = defocus(vUv, circleOfConfusion(vUv.y));\n}\n",uniforms:a,mipmapped:!0}]:[]}drawChain(e=j(this.settings)){const t=this.chainStages(e),a=t.length?this.chain.render(this.map.mapLibreMap.getCanvas(),t):void 0;this.artworkCanvas.style.display=a?"block":"none"}scheduleBloom(){this.get("bloom.intensity")>0&&this.bloomDraw.schedule()}drawBloom(){const e="block"===this.artworkCanvas.style.display?this.artworkCanvas:this.map.mapLibreMap.getCanvas(),t=Math.max(1,Math.round(.5*e.width)),a=Math.max(1,Math.round(.5*e.height));q(this.bloomCanvas,t,a);const s=this.bloomCanvas.getContext("2d");if(!s)return;const n=this.scopedSource(e,t,a)??e;s.filter=k(this.get("bloom.radius"),this.get("bloom.threshold"),.5),s.clearRect(0,0,t,a),s.drawImage(n,0,0,t,a)}scopedSource(e,t,a){const s=this.currentScopeMask();if(!s)return;this.scopeCanvas??=document.createElement("canvas");const n=this.scopeCanvas;q(n,t,a);const r=n.getContext("2d",{willReadFrequently:!0});return r?(r.clearRect(0,0,t,a),r.drawImage(e,0,0,t,a),l(r,s),n):void 0}currentScopeMask(){const e=this.get("bloom.only");if(0===e.length)return;const t=e.join("\n"),a=this.get("bloom.onlyTolerance");return this.scopeMask?.key===t&&this.scopeMask.tolerance===a||(this.scopeMask={key:t,tolerance:a,mask:this.buildScopeMaskFor(e,a)}),this.scopeMask.mask}buildScopeMaskFor(e,a){const s=(e=>e.some(e=>n.has(e)))(e);s&&this.requestStyling();const r=((e,a)=>a.flatMap(a=>{const s=n.get(a);return(s?s.map(t=>e?.get(t)):[a]).map(e=>"string"==typeof e?t(e):void 0).filter(e=>void 0!==e)}))(this.styling,e);return 0!==r.length||s&&!this.styling||this.warnScopeEmpty(e),((e,t)=>{const a=e.map(h).filter(e=>void 0!==e);if(0===a.length)return;const s=t*t,n=new Uint8Array(32768);for(let r=0;r<256;r+=8)for(let e=0;e<256;e+=8)for(let t=0;t<256;t+=8){const i=h([r,e,t]);i&&a.some(e=>{const t=i[0]-e[0],a=i[1]-e[1],n=i[2]-e[2];return t*t+a*a+n*n<=s})&&(n[o(r,e,t)]=1)}return n})(r,a)}warnScopeEmpty(e){this.warnedScope||(this.warnedScope=!0,console.warn(`[MapEffects] 'bloom.only: ${e.join(", ")}' found no colour to key on in the loaded style, so bloom is lighting the whole frame.`))}requestStyling(){this.stylingRequest??=e.get(this.map).then(e=>{this.styling=e,this.dropScopeMask()})}dropScopeMask(){this.scopeMask=void 0,this.scheduleBloom()}readBack(){const e=this.map.mapLibreMap;return new Promise(t=>{e.once("render",()=>{const a=e.getCanvas(),s=j(this.settings),n=document.createElement("canvas");n.width=a.width,n.height=a.height;const r=n.getContext("2d");if(r){const e=this.chainStages(s);r.drawImage(this.chain.render(a,e)??a,0,0),X(r,a.width,a.height,ee(a),s,this.map.styleLightDarkTheme,this.currentScopeMask())}t(n)}),e.triggerRepaint()})}settled(e=15e3){const t=this.map.mapLibreMap;return new Promise(a=>{const s=()=>{clearTimeout(n),t.off("idle",s),a()},n=setTimeout(s,e);t.once("idle",s)})}warnIfBufferNotPreserved(){if(this.warnedBuffer)return;const e=this.map.mapLibreMap.getCanvas(),t=(e.getContext("webgl2")??e.getContext("webgl"))?.getContextAttributes?.();t&&!t.preserveDrawingBuffer&&(this.warnedBuffer=!0,console.warn("[MapEffects] bloom, the depth of field, app passes and capture read the map canvas, which needs `mapLibre: { canvasContextAttributes: { preserveDrawingBuffer: true } }` on the map's options."))}warnNoWebGL2(){this.warnedNoWebGL2||(this.warnedNoWebGL2=!0,console.warn("[MapEffects] this browser gives no WebGL2 context, so passes from addPass neither compile nor run."))}assertKnob(e){if(!(e in w))throw new Error(`Unknown effect knob '${e}'. effectKnobIds lists every knob id.`)}validate(e,t){this.assertKnob(e);const a=w[e];if("colors"===a.kind){if(!Array.isArray(t))throw new RangeError(`Knob '${e}' expects a list of colours; got ${String(t)}.`);const a=t.map(i).find(e=>void 0!==e);if(a)throw new RangeError(`Knob '${e}': ${a}. effectKnobCatalogue lists the names it accepts.`);return}if("color"===a.kind){if("string"!=typeof t||!t)throw new RangeError(`Knob '${e}' expects a CSS colour; got ${String(t)}.`);return}const{range:s}=a;if("number"!=typeof t||!Number.isFinite(t))throw new RangeError(`Knob '${e}' expects a number; got ${String(t)}.`);if(t<s.min||t>s.max)throw new RangeError(`Knob '${e}' expects a number between ${s.min} and ${s.max}; got ${t}.`)}};export{ae as MapEffects,y as effectKnobCatalogue,v as effectKnobIds};
1
+ import{StylingModule as e,parseCssColor as t,stylingColorKnobIds as a,validateKnobValue as s}from"@tomtom-org/maps-sdk/map";var n=e=>e.split(".").slice(0,-1).map((e,t,a)=>a.slice(0,t+1).join(".")),r=(()=>{const e=/* @__PURE__ */new Map;for(const t of a)for(const a of[...n(t),t])e.set(a,[...e.get(a)??[],t]);return e})(),i=[...r.keys()],o=(e,t,a)=>32*(32*(e>>3)+(t>>3))+(a>>3),h=([e,t,a])=>{const s=Math.max(e,t,a);if(0!==s)return[e/s,t/s,a/s]},l=(e,t)=>{const a=e.getImageData(0,0,e.canvas.width,e.canvas.height);((e,t)=>{for(let a=0;a<e.length;a+=4)t[o(e[a],e[a+1],e[a+2])]||(e[a]=0,e[a+1]=0,e[a+2]=0)})(a.data,t),e.putImageData(a,0,0)},d={min:0,max:1,step:.05,bounds:"hard"},c="Emphasis on dark and driving styles.",u="Muting the base map under a data overlay.",f="Depth cue: the far context recedes as if seen through air.",g="Focus: the centre stays sharp and the edges lose their detail.",p="Depth on a tilted map: one band of distance stays sharp, the rest defocuses with distance.",m="Muting the base map under a data overlay; matching a brand.",b="Focus on the centre of the view.",w={"bloom.intensity":{kind:"number",description:"How strongly bright features (lit roads, traffic tubes, route lines) glow. 0 = off.",default:0,range:d,useCase:c},"bloom.radius":{kind:"number",description:"How far the glow spreads, in CSS pixels.",default:12,range:{min:2,max:40,step:1,bounds:"hard"},useCase:c},"bloom.threshold":{kind:"number",description:"Brightness below which nothing glows. Raise it so only the brightest features light up.",default:.55,range:d,useCase:c},"bloom.only":{kind:"colors",description:"The colours bloom may light: CSS colours, styling colour knobs or groups of them (`traffic`, `traffic.incidents.majorColor`), read from the loaded style. Only the pixels wearing one of them glow; empty lights the whole frame.",default:[],options:i,useCase:c},"bloom.onlyTolerance":{kind:"number",description:"How far a pixel may sit from one of the scope's colours and still glow. Raise it when antialiasing has mixed the colour with the map underneath; lower it when a neighbouring severity is glowing too.",default:.15,range:d,useCase:c},"grade.brightness":{kind:"number",description:"Brightness of the whole base map (1 = unchanged).",default:1,range:{min:.5,max:1.5,step:.05,bounds:"hard"},useCase:u},"grade.contrast":{kind:"number",description:"Contrast of the whole base map (1 = unchanged).",default:1,range:{min:.5,max:1.5,step:.05,bounds:"hard"},useCase:u},"grade.saturation":{kind:"number",description:"Colour saturation of the whole base map (1 = unchanged, 0 = greyscale).",default:1,range:{min:0,max:1.5,step:.05,bounds:"hard"},useCase:u},"fog.intensity":{kind:"number",description:"Haze over the edges of the view: blur, plus a wash towards the colour of the air — pale on a light map, dark on a dark one. Reads as distance. 0 = off.",default:0,range:d,useCase:f},"fog.reach":{kind:"number",description:"How far the fog reaches in from the edges (1 = to the centre).",default:.4,range:d,useCase:f},"edgeBlur.intensity":{kind:"number",description:"Blur over the edges of the view, in CSS pixels, leaving their colour untouched. Reads as a lens, not as distance — `fog` is the effect that also washes the rim out. 0 = off.",default:0,range:{min:0,max:30,step:1,bounds:"hard"},useCase:g},"edgeBlur.reach":{kind:"number",description:"How far the edge blur reaches in from the edges (1 = to the centre).",default:.5,range:d,useCase:g},"depthOfField.intensity":{kind:"number",description:"The widest circle of confusion, in CSS pixels — the blur the most out-of-focus distance gets. Needs a tilted camera: a map seen from straight above has no depth, and the effect ends there on its own. 0 = off.",default:0,range:{min:0,max:30,step:1,bounds:"hard"},useCase:p},"depthOfField.focus":{kind:"number",description:"Which distance is sharp, across what the frame shows: 0 the nearest ground at the bottom of the view, 1 the farthest at the top.",default:0,range:d,useCase:p},"depthOfField.band":{kind:"number",description:"How much of that depth stays sharp — the field itself. Thin is the miniature; wide holds a whole run of the map. A gentle tilt has little depth, so the same value covers more of the view.",default:.3,range:d,useCase:p},"depthOfField.bokeh":{kind:"number",description:"Whether an out-of-focus highlight fades or comes back as a disc of light. 0 averages the light away, as a blur does; turned up, a lit road or a traffic tube keeps its brightness across the whole circle it is spread over.",default:.5,range:d,useCase:p},"tint.color":{kind:"color",description:"A flat colour laid over the whole map.",default:"#1f2937",useCase:m},"tint.opacity":{kind:"number",description:"Opacity of the tint. 0 = off.",default:0,range:d,useCase:m},"vignette.intensity":{kind:"number",description:"Darkening (negative) or lightening (positive) towards the edges. 0 = off.",default:0,range:{min:-1,max:1,step:.05,bounds:"hard"},useCase:b},"vignette.reach":{kind:"number",description:"How far the vignette reaches in from the edges (1 = to the centre).",default:.45,range:d,useCase:b}},v=Object.keys(w),y=v.map(e=>({id:e,...w[e]})),x=(e,t,a)=>Math.min(Math.max(e,t),a),k=e=>x(e,0,1),T=(e,t,a=1)=>{const s=.92*k(t),n=(1+s)/(1-s);return`brightness(${(1/(1+s)).toFixed(4)}) contrast(${n.toFixed(3)}) blur(${e*a}px) saturate(1.35)`},E=(e,t,a)=>[1===e?"":`brightness(${e})`,1===t?"":`contrast(${t})`,1===a?"":`saturate(${a})`].filter(Boolean).join(" "),S=(e,t,a)=>`radial-gradient(ellipse farthest-corner at center, ${e} ${Math.round(100*(1-k(a)))}%, ${t} 100%)`,C=(e,t)=>{const{inner:a,outer:s}=M(e);return S(a,s,t)},M=e=>{const t=e<0?"0, 0, 0":"255, 255, 255";return{inner:`rgba(${t}, 0)`,outer:`rgba(${t}, ${Math.min(Math.abs(e),1)})`}},R=e=>S("transparent","black",e),L=e=>e*Math.PI/180,B=(e,t,a)=>{const s=(({pitch:e,verticalFieldOfView:t})=>{const a=Math.tan(L(x(e,0,89))),s=2*Math.tan(L(x(t,1,175))/2)*a;return s/(1+s/2)})(t);if(!(s<=0||e.blurCssPx<=0))return{uDepth:s,uFocus:Math.min(k(e.focus)*s,1),uBand:.5*k(e.band),uBlur:Math.max(e.blurCssPx,0)*a.scale,uBokeh:k(e.bokeh),uTexel:[1/Math.max(a.width,1),1/Math.max(a.height,1)]}},P={light:{rgb:"238, 240, 244",grade:"brightness(1.08) contrast(0.82) saturate(0.7)"},dark:{rgb:"16, 22, 33",grade:"brightness(0.92) contrast(0.82) saturate(0.7)"}},A="saturate(1.08)",D=e=>P[e].grade,_=e=>2+6*e,F=(e,t)=>`blur(${_(e)}px) ${D(t)}`,U=(e,t)=>{const{rgb:a}=P[t];return{inner:`rgba(${a}, 0)`,outer:`rgba(${a}, ${.65*e})`}},I=(e,t,a)=>{const{inner:s,outer:n}=U(e,a);return S(s,n,t)},O=(e,t,a,s)=>{const n=document.createElement("canvas");n.width=t,n.height=a;const r=n.getContext("2d");if(r)return r.filter=s,r.drawImage(e.canvas,0,0),r.filter="none",r},N=(e,t,a,s=1)=>{e.save(),e.globalCompositeOperation=a,e.globalAlpha=s,e.drawImage(t.canvas,0,0),e.restore()},$=(e,t,a,s,n,r)=>{const i=t/Math.SQRT2,o=a/Math.SQRT2,h=e.createRadialGradient(0,0,1-s,0,0,1);h.addColorStop(0,n),h.addColorStop(1,r),e.save(),e.translate(t/2,a/2),e.scale(i,o),e.fillStyle=h,e.fillRect(-t/2/i,-a/2/o,t/i,a/o),e.restore()},G=(e,t,a,s,n,r,i)=>{const o=O(e,t,a,`blur(${n*s}px) ${r}`.trim());o&&(o.globalCompositeOperation="destination-in",i(o),e.drawImage(o.canvas,0,0))},W=(e,t,a,s)=>{const n=O(e,t,a,"none");return n&&l(n,s),n},X=(e,t,a,s,n,r,i)=>{n["bloom.intensity"]>0&&((e,t,a,s,n,r)=>{const i=T(n["bloom.radius"],n["bloom.threshold"],s),o=r?W(e,t,a,r):e;if(!o)return;const h=O(o,t,a,i);h&&N(e,h,"screen",n["bloom.intensity"])})(e,t,a,s,n,i);const o=E(n["grade.brightness"],n["grade.contrast"],n["grade.saturation"]);o&&((e,t,a,s)=>{const n=O(e,t,a,s);n&&N(e,n,"copy")})(e,t,a,o);const h=e=>s=>$(s,t,a,e,"rgba(0,0,0,0)","rgba(0,0,0,1)");if(n["fog.intensity"]>0){const i=n["fog.intensity"],o=n["fog.reach"],{inner:l,outer:d}=U(i,r),c=_(i);G(e,t,a,s,c,D(r),h(o)),$(e,t,a,o,l,d)}if(n["edgeBlur.intensity"]>0){const r=n["edgeBlur.intensity"];G(e,t,a,s,r,A,h(n["edgeBlur.reach"]))}if(n["tint.opacity"]>0&&(e.save(),e.globalAlpha=n["tint.opacity"],e.fillStyle=n["tint.color"],e.fillRect(0,0,t,a),e.restore()),0!==n["vignette.intensity"]){const{inner:s,outer:r}=M(n["vignette.intensity"]);$(e,t,a,n["vignette.reach"],s,r)}},j=e=>Object.fromEntries(v.map(t=>[t,e[t]??w[t].default])),q=e=>{e.texParameteri(e.TEXTURE_2D,e.TEXTURE_WRAP_S,e.CLAMP_TO_EDGE),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_WRAP_T,e.CLAMP_TO_EDGE),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_MAG_FILTER,e.LINEAR),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_MIN_FILTER,e.LINEAR)},H=(e,t,a)=>{e.width===t&&e.height===a||(e.width=t,e.height=a)},z=(e,t,a)=>{const s=e.createShader(t);if(!s)return"the context would not create a shader";if(e.shaderSource(s,a),e.compileShader(s),e.getShaderParameter(s,e.COMPILE_STATUS))return s;const n=e.getShaderInfoLog(s)??"";return e.deleteShader(s),`failed to compile: ${n}`},V=class{canvas;gl;programs=/* @__PURE__ */new Map;failures=/* @__PURE__ */new Set;sourceTexture;targets=[];targetSize={width:0,height:0};lost=!1;constructor(){this.canvas=document.createElement("canvas"),this.canvas.addEventListener("webglcontextlost",e=>{e.preventDefault(),this.lost=!0}),this.canvas.addEventListener("webglcontextrestored",()=>{this.programs.clear(),this.failures.clear(),this.sourceTexture=void 0,this.targets=[],this.targetSize={width:0,height:0},this.lost=!1}),this.gl=this.canvas.getContext("webgl2",{alpha:!0,antialias:!1,premultipliedAlpha:!1,preserveDrawingBuffer:!0})??void 0}render(e,t){const a=this.gl;if(!a||this.lost||!t.length||!e.width||!e.height)return;H(this.canvas,e.width,e.height);let s=this.uploadSource(e,t[0].mipmapped);if(!(!s||t.length>1&&!this.holdTargets(a))){for(let e=0;e<t.length;e++){const n=e===t.length-1?void 0:this.targets[e%this.targets.length];this.runStage(a,t[e],s,n),n&&(s=n.texture,t[e+1].mipmapped&&this.mipmap(a,s))}return this.canvas}}get hasContext(){return void 0!==this.gl}compileError(e){const t=this.gl;if(!t||this.lost)return;const a=this.cachedProgram(t,e);return"string"==typeof a?a:void 0}release(e){const t=this.programs.get(e);t&&this.gl?.deleteProgram(t),this.programs.delete(e),this.failures.delete(e)}remove(){this.gl?.getExtension("WEBGL_lose_context")?.loseContext()}holdTargets(e){const{width:t,height:a}=this.canvas;if(2===this.targets.length&&this.targetSize.width===t&&this.targetSize.height===a)return!0;for(this.releaseTargets(e);this.targets.length<2;){const s=e.createTexture(),n=e.createFramebuffer();if(!s||!n)return!1;e.bindTexture(e.TEXTURE_2D,s),e.texImage2D(e.TEXTURE_2D,0,e.RGBA,t,a,0,e.RGBA,e.UNSIGNED_BYTE,null),q(e),e.bindFramebuffer(e.FRAMEBUFFER,n),e.framebufferTexture2D(e.FRAMEBUFFER,e.COLOR_ATTACHMENT0,e.TEXTURE_2D,s,0),this.targets.push({texture:s,framebuffer:n})}return this.targetSize={width:t,height:a},!0}releaseTargets(e){for(const{texture:t,framebuffer:a}of this.targets)e.deleteTexture(t),e.deleteFramebuffer(a);this.targets=[]}mipmap(e,t){e.bindTexture(e.TEXTURE_2D,t),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_MIN_FILTER,e.LINEAR_MIPMAP_LINEAR),e.generateMipmap(e.TEXTURE_2D)}uploadSource(e,t=!1){const a=this.gl;if(a&&(this.sourceTexture??=a.createTexture()??void 0,this.sourceTexture))return a.bindTexture(a.TEXTURE_2D,this.sourceTexture),a.pixelStorei(a.UNPACK_FLIP_Y_WEBGL,!0),a.texImage2D(a.TEXTURE_2D,0,a.RGBA,a.RGBA,a.UNSIGNED_BYTE,e),q(a),t&&this.mipmap(a,this.sourceTexture),this.sourceTexture}runStage(e,t,a,s){const n=this.programOf(e,t.fragment);if(n){e.bindFramebuffer(e.FRAMEBUFFER,s?.framebuffer??null),e.viewport(0,0,this.canvas.width,this.canvas.height),e.useProgram(n),e.activeTexture(e.TEXTURE0),e.bindTexture(e.TEXTURE_2D,a),e.uniform1i(e.getUniformLocation(n,"uSource"),0);for(const[a,s]of Object.entries(t.uniforms)){const t=e.getUniformLocation(n,a);t&&("number"==typeof s?e.uniform1f(t,s):e.uniform2f(t,s[0],s[1]))}e.drawArrays(e.TRIANGLES,0,3)}}programOf(e,t){if(this.failures.has(t))return;const a=this.cachedProgram(e,t);if("string"!=typeof a)return a;this.failures.add(t),console.warn(`[MapEffects] shader ${a}`)}cachedProgram(e,t){const a=this.programs.get(t);if(a)return a;const s=this.build(e,t);return"string"!=typeof s&&this.programs.set(t,s),s}build(e,t){const a=[z(e,e.VERTEX_SHADER,"#version 300 es\n// ⚠️ The directive above has to be the literal first line — ANGLE rejects even a comment before it,\n// and the whole program then fails to compile.\n//\n// The vertex half of every pass the substrate runs — see ../screenPass.ts, which loads this file\n// with Vite's `?raw`.\n//\n// It belongs to the substrate rather than to any one pass, because what it interprets is the\n// substrate's own geometry: a single full-screen triangle. One primitive rather than a quad's two,\n// so no diagonal seam is rasterised twice, and the corners fall out of `gl_VertexID` so there is no\n// buffer to bind.\n//\n// ⚠️ `vUv` runs with v increasing upward, matching the flipped upload in `uploadSource` — so v = 0\n// is the bottom of the frame, which on a tilted map is the nearest ground. The depth of field\n// depends on that, so it is a contract rather than an accident.\n\nout vec2 vUv;\n\nvoid main() {\n vec2 corner = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n vUv = corner;\n gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);\n}\n"),z(e,e.FRAGMENT_SHADER,`#version 300 es\n// ⚠️ The directive above has to be the literal first line — ANGLE rejects even a comment before it —\n// which is also why it lives here rather than in a stage: a stage is appended to this file, and a\n// second directive anywhere is a compile error. ../../tests/effectFilters.test.ts holds that.\n//\n// Not a program: the preamble ../screenPass.ts prepends to every fragment stage it compiles. It\n// carries what is the same for all of them — the source texture, the varying it is read at, the\n// colour that is written — so a pass declares only what is its own, and the substrate stays the one\n// place those names are spelled.\n//\n// GLSL ES 3.00, for the two things ES 1.00 cannot give a gather: \`textureLod\`, without which a tap\n// cannot read a prefiltered mip level, and a loop it may leave early.\n\nprecision highp float;\nprecision highp sampler2D;\n\nuniform sampler2D uSource;\nin vec2 vUv;\nout vec4 fragColor;\n${t}`)],s=a.find(e=>"string"==typeof e),n=a.filter(e=>"string"!=typeof e),r=s?null:e.createProgram();if(r){for(const t of n)e.attachShader(r,t);e.linkProgram(r)}for(const o of n)e.deleteShader(o);if(s)return s;if(!r)return"the context would not create a program";if(e.getProgramParameter(r,e.LINK_STATUS))return r;const i=e.getProgramInfoLog(r)??"";return e.deleteProgram(r),`failed to link: ${i}`}},K={"before-depth-of-field":!0,"after-depth-of-field":!0},Y=["bloom","grade","fog","edgeBlur","tint","vignette"],Q=e=>({backdropFilter:e,webkitBackdropFilter:e}),J=e=>({maskImage:e,webkitMaskImage:e}),Z={position:"absolute",inset:"0",width:"100%",height:"100%",pointerEvents:"none",display:"none"},ee=e=>e.width/Math.max(e.clientWidth,1),te=e=>{let t;return{schedule:()=>{void 0===t&&(t=requestAnimationFrame(()=>{t=void 0,e()}))},cancel:()=>{void 0!==t&&cancelAnimationFrame(t),t=void 0}}},ae=class{map;settings={};passes=[];container;overlays;bloomCanvas;artworkCanvas;chain=new V;chainDraw=te(()=>this.drawChain());bloomDraw=te(()=>this.drawBloom());styling;stylingRequest;scopeMask;scopeCanvas;warnedBuffer=!1;warnedScope=!1;warnedNoWebGL2=!1;warnedUniforms=/* @__PURE__ */new WeakSet;onStyleData=()=>this.dropScopeMask();onRender=()=>{this.chainDraw.schedule(),this.scheduleBloom()};constructor(e,t={}){this.map=e;const a=this.map.mapLibreMap.getContainer();this.container=document.createElement("div"),this.container.className="tomtom-map-effects",Object.assign(this.container.style,{position:"absolute",inset:"0",pointerEvents:"none",overflow:"hidden"}),a.querySelector(".maplibregl-canvas-container")?.insertAdjacentElement("afterend",this.container)??a.appendChild(this.container),this.bloomCanvas=document.createElement("canvas"),this.overlays=Object.fromEntries(Y.map(e=>{const t="bloom"===e?this.bloomCanvas:document.createElement("div");return t.dataset.effect=e,Object.assign(t.style,Z),this.container.appendChild(t),[e,t]})),this.bloomCanvas.style.mixBlendMode="screen",this.artworkCanvas=this.chain.canvas,this.artworkCanvas.dataset.effect="artwork",Object.assign(this.artworkCanvas.style,Z),this.container.insertAdjacentElement("afterbegin",this.artworkCanvas),this.map.mapLibreMap.on("render",this.onRender),this.map.mapLibreMap.on("styledata",this.onStyleData),this.set(t)}cameraAngles(){const e=this.map.mapLibreMap;return{pitch:e.getPitch(),verticalFieldOfView:e.getVerticalFieldOfView()}}set(e){for(const[t,a]of Object.entries(e))this.validate(t,a);this.settings={...this.settings,...e},this.render()}get(e){return this.assertKnob(e),this.settings[e]??w[e].default}reset(e){if(void 0===e)this.settings={};else{this.assertKnob(e);const{[e]:t,...a}=this.settings;this.settings=a}this.render()}getConfig(){return{...this.settings}}applyConfig(e){this.settings={},this.set(e)}addPass(e){if(!e.id)throw new Error("A pass needs an id, to be replaced or removed by.");if(void 0!==e.placement&&!Object.hasOwn(K,e.placement))throw new RangeError(`Pass '${e.id}' expects a placement of ${Object.keys(K).join(" or ")}; got ${String(e.placement)}.`);this.chain.hasContext||this.warnNoWebGL2();const t=this.chain.compileError(e.fragment);if(t)throw new Error(`Pass '${e.id}' ${t}`);const a={...e},s=this.passes.find(t=>t.id===e.id);this.passes=s?this.passes.map(e=>e===s?a:e):[...this.passes,a],s&&this.releaseUnused(s.fragment),this.warnIfBufferNotPreserved(),this.render()}removePass(e){const t=this.passes.find(t=>t.id===e);t&&(this.passes=this.passes.filter(e=>e!==t),this.releaseUnused(t.fragment),this.render())}async capture(e={}){this.warnIfBufferNotPreserved();const t=this.map.mapLibreMap,a=t,s=t.getPixelRatio(),n=e.pixelRatio??s,r=s!==(globalThis.devicePixelRatio||1),i=Math.abs(n-s)>.01;i&&(a.setPixelRatio(n),await this.settled());try{return await this.readBack()}finally{i&&a.setPixelRatio(r?s:null)}}remove(){this.map.mapLibreMap.off("render",this.onRender),this.map.mapLibreMap.off("styledata",this.onStyleData),this.bloomDraw.cancel(),this.chainDraw.cancel(),this.chain.remove(),this.container.remove()}render(){const e=j(this.settings),t=(e,t,a={})=>{const s=this.overlays[e];s.style.display=t?"block":"none",Object.assign(s.style,a)},a=E(e["grade.brightness"],e["grade.contrast"],e["grade.saturation"]);t("grade",""!==a,Q(a));const s=this.map.styleLightDarkTheme;var n;t("fog",e["fog.intensity"]>0,{...Q(F(e["fog.intensity"],s)),...J(R(e["fog.reach"])),backgroundImage:I(e["fog.intensity"],e["fog.reach"],s)}),t("edgeBlur",e["edgeBlur.intensity"]>0,{...Q((n=e["edgeBlur.intensity"],`blur(${n}px) ${A}`)),...J(R(e["edgeBlur.reach"]))}),e["depthOfField.intensity"]>0&&this.warnIfBufferNotPreserved(),this.drawChain(e),t("tint",e["tint.opacity"]>0,{backgroundColor:e["tint.color"],opacity:String(e["tint.opacity"])}),t("vignette",0!==e["vignette.intensity"],{backgroundImage:C(e["vignette.intensity"],e["vignette.reach"])});const r=e["bloom.intensity"]>0;t("bloom",r,{opacity:String(e["bloom.intensity"])}),r&&(this.warnIfBufferNotPreserved(),this.scheduleBloom())}chainStages(e){return[...this.passStages("before-depth-of-field"),...this.depthOfFieldStages(e),...this.passStages("after-depth-of-field")]}passStages(e){return this.passes.filter(t=>(t.placement??"before-depth-of-field")===e).flatMap(e=>{const t=this.uniformsOf(e);return t?[{fragment:e.fragment,uniforms:t}]:[]})}uniformsOf(e){const{uniforms:t={}}=e;if("function"!=typeof t)return t;try{return t()}catch(a){return void(this.warnedUniforms.has(e)||(this.warnedUniforms.add(e),console.warn(`[MapEffects] pass '${e.id}' is skipped while its uniforms() throws:`,a)))}}releaseUnused(e){this.passes.some(t=>t.fragment===e)||this.chain.release(e)}depthOfFieldStages(e){const t=this.map.mapLibreMap.getCanvas(),a=B({focus:e["depthOfField.focus"],band:e["depthOfField.band"],blurCssPx:e["depthOfField.intensity"],bokeh:e["depthOfField.bokeh"]},this.cameraAngles(),{width:t.width,height:t.height,scale:ee(t)});return a?[{fragment:"// The depth-of-field pass: the circle of confusion a tilted camera implies at each row of the\n// picture, and the aperture gathered over it. See ../defocusShader.ts, which loads this file and\n// names the uniforms below, and `defocusUniforms` in ../../effectFilters.ts, which works out the\n// optics this only applies.\n//\n// The declarations the substrate already made — `uSource`, `vUv`, `fragColor`, the precisions and\n// the version directive — are in ./fragmentPreamble.glsl and must not be repeated here.\n\n// What each of these means is on `DefocusUniforms` in ../defocusShader.ts, which is also where the\n// spelling is held to match.\nuniform float uDepth;\nuniform float uFocus;\nuniform float uBand;\nuniform float uBlur;\nuniform float uBokeh;\nuniform vec2 uTexel;\n\n// A tap budget rather than a tap count: the loop is bounded by a constant so a wide disc stays\n// affordable, and filled to `taps` so a narrow one stays smooth.\nconst int MAX_TAPS = 48;\n// The golden angle: successive taps land as far from every previous one as a turn can put them, so\n// a partly filled disc is still an even disc.\nconst float GOLDEN_ANGLE = 2.39996323;\n\n// Where a row falls on the depth scale: 0 at the nearest ground, 1 at the horizon. Linear in the\n// row because reciprocal distance across a plane really is affine in screen space.\nfloat depthAt(float v) {\n return uDepth * v;\n}\n\n/**\n * The circle of confusion at a row, in device pixels. Proportional to the distance from the plane\n * of focus in reciprocal units, which is what a thin lens does. The sharp band is taken out of the\n * ramp and the remainder stretched back over the full range, so widening the field moves where the\n * blur starts without also weakening how far it goes.\n */\nfloat circleOfConfusion(float v) {\n float defocus = abs(depthAt(v) - uFocus) - uBand;\n return uBlur * clamp(defocus / max(1.0 - uBand, 0.001), 0.0, 1.0);\n}\n\n// Rec. 709 luma.\nfloat luma(vec3 colour) {\n return dot(colour, vec3(0.2126, 0.7152, 0.0722));\n}\n\n/**\n * What makes it a bokeh rather than a blur: a lens does not average the light it cannot resolve, it\n * spreads it, so every point arrives as an image of the aperture at its own brightness. Weighting a\n * sample by the fourth power of its brightness keeps a highlight's value across the whole disc it\n * is spread over — at half brightness the extra weight is a sixteenth, so ordinary map colour still\n * averages as it should. Deliberately not energy-conserving: normalising that away leaves a blur.\n */\nfloat bokehWeight(vec3 colour) {\n float bright = luma(colour);\n float fourth = bright * bright * bright * bright;\n return 1.0 + uBokeh * 8.0 * fourth;\n}\n\n// Per-pixel and stable, so a still picture is still: the angle the disc below is spun by.\nfloat hash(vec2 at) {\n vec3 spread = fract(vec3(at.xyx) * 0.1031);\n spread += dot(spread, spread.yzx + 33.33);\n return fract((spread.x + spread.y) * spread.z);\n}\n\n/**\n * The aperture, gathered: a disc of `coc` diameter, weighted so the highlights survive it.\n *\n * Two dozen taps across a wide disc is a scatter of ghosts rather than a blur, and the fix is not\n * more taps but bigger ones — each is read from the mip level whose texels are about as wide as the\n * gaps between them, so what is sampled is already the average of what was skipped. The two numbers\n * are tied to each other rather than tuned apart, which is what keeps a wide disc the same picture\n * as a narrow one instead of a coarser one.\n */\nvec4 defocus(vec2 uv, float coc) {\n float radius = coc * 0.5;\n // Inside a texel there is nothing to gather, and the sharp band all comes through here — it has\n // to hand back the source unchanged.\n if (radius < 0.75) return textureLod(uSource, uv, 0.0);\n\n int taps = int(clamp(radius * 1.5, 12.0, float(MAX_TAPS)));\n float spacing = radius * sqrt(3.14159265 / float(taps));\n float level = max(log2(spacing), 0.0);\n // Without the spin every pixel samples the same directions and the residual undersampling lines\n // up into streaks; broken up, what is left reads as grain.\n float spin = hash(gl_FragCoord.xy) * 6.28318531;\n\n vec4 centre = textureLod(uSource, uv, level);\n float weight = bokehWeight(centre.rgb);\n vec4 total = vec4(centre.rgb * centre.a, centre.a) * weight;\n float carried = weight;\n\n for (int index = 0; index < MAX_TAPS; index++) {\n if (index >= taps) break;\n\n // The square root of the fraction, not the fraction: a disc's area grows with the square of\n // its radius, so evenly spaced radii would crowd every tap into the middle.\n float fraction = (float(index) + 0.5) / float(taps);\n float distance = radius * sqrt(fraction);\n float angle = float(index) * GOLDEN_ANGLE + spin;\n vec2 offset = vec2(cos(angle), sin(angle)) * distance;\n\n vec4 sampled = textureLod(uSource, uv + offset * uTexel, level);\n float tapWeight = bokehWeight(sampled.rgb);\n total += vec4(sampled.rgb * sampled.a, sampled.a) * tapWeight;\n carried += tapWeight;\n }\n\n // Colour comes back out of the premultiplied sum by its own alpha, the alpha by the weight.\n return vec4(total.rgb / max(total.a, 0.0001), total.a / carried);\n}\n\nvoid main() {\n // The texture is uploaded flipped, so this row runs from the bottom of the frame — the nearest\n // ground — which is the direction the depth ramp is measured in.\n fragColor = defocus(vUv, circleOfConfusion(vUv.y));\n}\n",uniforms:a,mipmapped:!0}]:[]}drawChain(e=j(this.settings)){const t=this.chainStages(e),a=t.length?this.chain.render(this.map.mapLibreMap.getCanvas(),t):void 0;this.artworkCanvas.style.display=a?"block":"none"}scheduleBloom(){this.get("bloom.intensity")>0&&this.bloomDraw.schedule()}drawBloom(){const e="block"===this.artworkCanvas.style.display?this.artworkCanvas:this.map.mapLibreMap.getCanvas(),t=Math.max(1,Math.round(.5*e.width)),a=Math.max(1,Math.round(.5*e.height));H(this.bloomCanvas,t,a);const s=this.bloomCanvas.getContext("2d");if(!s)return;const n=this.scopedSource(e,t,a)??e;s.filter=T(this.get("bloom.radius"),this.get("bloom.threshold"),.5),s.clearRect(0,0,t,a),s.drawImage(n,0,0,t,a)}scopedSource(e,t,a){const s=this.currentScopeMask();if(!s)return;this.scopeCanvas??=document.createElement("canvas");const n=this.scopeCanvas;H(n,t,a);const r=n.getContext("2d",{willReadFrequently:!0});return r?(r.clearRect(0,0,t,a),r.drawImage(e,0,0,t,a),l(r,s),n):void 0}currentScopeMask(){const e=this.get("bloom.only");if(0===e.length)return;const t=e.join("\n"),a=this.get("bloom.onlyTolerance");return this.scopeMask?.key===t&&this.scopeMask.tolerance===a||(this.scopeMask={key:t,tolerance:a,mask:this.buildScopeMaskFor(e,a)}),this.scopeMask.mask}buildScopeMaskFor(e,a){const s=(e=>e.some(e=>r.has(e)))(e);s&&this.requestStyling();const n=((e,a)=>a.flatMap(a=>{const s=r.get(a);return(s?s.map(t=>e?.get(t)):[a]).map(e=>"string"==typeof e?t(e):void 0).filter(e=>void 0!==e)}))(this.styling,e);return 0!==n.length||s&&!this.styling||this.warnScopeEmpty(e),((e,t)=>{const a=e.map(h).filter(e=>void 0!==e);if(0===a.length)return;const s=t*t,n=new Uint8Array(32768);for(let r=0;r<256;r+=8)for(let e=0;e<256;e+=8)for(let t=0;t<256;t+=8){const i=h([r,e,t]);i&&a.some(e=>{const t=i[0]-e[0],a=i[1]-e[1],n=i[2]-e[2];return t*t+a*a+n*n<=s})&&(n[o(r,e,t)]=1)}return n})(n,a)}warnScopeEmpty(e){this.warnedScope||(this.warnedScope=!0,console.warn(`[MapEffects] 'bloom.only: ${e.join(", ")}' found no colour to key on in the loaded style, so bloom is lighting the whole frame.`))}requestStyling(){this.stylingRequest??=e.get(this.map).then(e=>{this.styling=e,this.dropScopeMask()})}dropScopeMask(){this.scopeMask=void 0,this.scheduleBloom()}readBack(){const e=this.map.mapLibreMap;return new Promise(t=>{e.once("render",()=>{const a=e.getCanvas(),s=j(this.settings),n=document.createElement("canvas");n.width=a.width,n.height=a.height;const r=n.getContext("2d");if(r){const e=this.chainStages(s);r.drawImage(this.chain.render(a,e)??a,0,0),X(r,a.width,a.height,ee(a),s,this.map.styleLightDarkTheme,this.currentScopeMask())}t(n)}),e.triggerRepaint()})}settled(e=15e3){const t=this.map.mapLibreMap;return new Promise(a=>{const s=()=>{clearTimeout(n),t.off("idle",s),a()},n=setTimeout(s,e);t.once("idle",s)})}warnIfBufferNotPreserved(){if(this.warnedBuffer)return;const e=this.map.mapLibreMap.getCanvas(),t=(e.getContext("webgl2")??e.getContext("webgl"))?.getContextAttributes?.();t&&!t.preserveDrawingBuffer&&(this.warnedBuffer=!0,console.warn("[MapEffects] bloom, the depth of field, app passes and capture read the map canvas, which needs `mapLibre: { canvasContextAttributes: { preserveDrawingBuffer: true } }` on the map's options."))}warnNoWebGL2(){this.warnedNoWebGL2||(this.warnedNoWebGL2=!0,console.warn("[MapEffects] this browser gives no WebGL2 context, so passes from addPass neither compile nor run."))}assertKnob(e){if(!(e in w))throw new Error(`Unknown effect knob '${e}'. effectKnobIds lists every knob id.`)}validate(e,t){this.assertKnob(e),s({id:e,...w[e]},t)}};export{ae as MapEffects,y as effectKnobCatalogue,v as effectKnobIds};
2
2
  //# sourceMappingURL=index.es.js.map