craft-library 0.5.0 → 0.7.0

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/pin.js ADDED
@@ -0,0 +1,977 @@
1
+ /* Pin de mapa · Sara Lobo Library (saralobolibrary.com). A city map in perspective with a glass map pin: a click or a tap
2
+ on a street sends the pin flying to the nearest curb, where it sticks its tip in; the map is in focus there and soft
3
+ further away, the streets carry their names, and a card under the map names the address and confirms it.
4
+ One ES module, three.js 0.169 its only dependency. Made with craft by Sara Lobo. */
5
+ import * as THREE from 'three';
6
+
7
+ // the words the piece shows, in English; the library's page hands in its Portuguese
8
+ const LABELS = {
9
+ title: 'Delivery address', confirm: 'Confirm', confirmed: 'Confirmed', nogl: '3D is not available in this browser.',
10
+ label: 'A city map with a glass pin: click a street and the pin flies to the curb; the address shows in a card to confirm.'
11
+ };
12
+ const CSS = '.pin-root{--pin-paper:#EDEEF2;--pin-muted:#A3A5AE;--pin-faint:#73757F;--pin-sans:\'DM Sans\',ui-sans-serif,system-ui,-apple-system,\'Segoe UI\',sans-serif;position:relative;overflow:hidden;font-family:var(--pin-sans);-webkit-font-smoothing:antialiased;color:var(--pin-paper);text-align:left;line-height:normal;letter-spacing:normal}\n.pin-root *{box-sizing:border-box}\n.pin-nogl{position:absolute;inset:0;display:grid;place-items:center;padding:24px;text-align:center;font-size:14px;color:var(--pin-faint)}\n.pin-tag{position:absolute;left:0;top:0;z-index:2;height:28px;border-radius:8px;background:#1B1C20;border:1px solid rgba(255,255,255,.09);box-shadow:0 6px 18px rgba(0,0,0,.35);opacity:0;pointer-events:none;overflow:hidden;will-change:transform,opacity;transition:width .26s cubic-bezier(.2,.8,.2,1)}\n.pin-tag span{position:absolute;left:0;top:0;height:100%;display:flex;align-items:center;gap:6px;padding:0 10px;white-space:nowrap;font:500 12.5px/1 var(--pin-sans);color:var(--pin-paper);opacity:0;transition:opacity .18s ease}\n.pin-tag i{font-style:normal;font-weight:400;color:var(--pin-muted)}\n.pin-tag i::before{content:\'·\';margin-right:6px;color:var(--pin-faint)}\n.pin-mapl{position:absolute;inset:0;z-index:1;overflow:hidden;pointer-events:none}\n.pin-ml{position:absolute;left:0;top:0;white-space:nowrap;font:500 10.5px/1 var(--pin-sans);letter-spacing:.01em;color:#8C8E99;text-shadow:0 0 3px rgba(8,9,10,.95),0 0 7px rgba(8,9,10,.7);will-change:transform,opacity,filter}\n.pin-ml.av{font-size:11px;color:#B5B7C2}\n.pin-ml.poi{color:#AAA6D8}\n.pin-ml.park{color:#7F998F}\n.pin-ml.river{font-style:italic;color:#7287BD;letter-spacing:.06em}\n.pin-addr{position:absolute;left:12px;right:12px;bottom:12px;z-index:2;display:flex;align-items:center;gap:12px;padding:11px 11px 11px 14px;border-radius:14px;background:#17181C;border:1px solid rgba(255,255,255,.08);box-shadow:0 10px 30px rgba(0,0,0,.45)}\n.pin-addr-t{display:flex;flex-direction:column;gap:3px;min-width:0;flex:1;transition:opacity .14s ease,transform .14s ease}\n.pin-addr-t.swap{opacity:0;transform:translateY(2px)}\n.pin-addr-k{font-size:11.5px;color:var(--pin-faint)}\n.pin-addr-a{font-size:15px;font-weight:500;letter-spacing:-.005em;color:var(--pin-paper);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.pin-addr-b{font-size:12.5px;color:var(--pin-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.pin-addr-go{flex:none;display:inline-flex;align-items:center;gap:6px;height:34px;padding:0 16px;border-radius:999px;border:0;background:var(--pin-paper);color:#0A0B0C;font:500 13px var(--pin-sans);cursor:pointer;transition:background-color .25s ease,color .25s ease,opacity .25s ease,padding .25s ease}\n.pin-addr-go[aria-disabled="true"]{opacity:.42;cursor:default}\n.pin-addr-go:focus-visible{outline:2px solid var(--pin-paper);outline-offset:2px}\n.pin-addr-go svg{width:0;height:16px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:14;stroke-dashoffset:14;transition:width .25s ease,stroke-dashoffset .35s ease .1s}\n.pin-addr-go.done{background:rgba(255,255,255,.08);color:var(--pin-paper);opacity:1;padding:0 16px 0 13px}\n.pin-addr-go.done svg{width:16px;stroke-dashoffset:0}';
13
+ function css() {
14
+ if (document.getElementById('pin-style')) return;
15
+ const s = document.createElement('style'); s.id = 'pin-style'; s.textContent = CSS; document.head.appendChild(s);
16
+ }
17
+ function fonts() { // DM Sans, as the piece was drawn with it
18
+ if (document.querySelector('link[data-pin-fonts]')) return;
19
+ const l = document.createElement('link'); l.rel = 'stylesheet'; l.dataset.pinFonts = '';
20
+ l.href = 'https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500&display=swap';
21
+ document.head.appendChild(l);
22
+ }
23
+
24
+ const G = 5.2, TAU = Math.PI * 2;
25
+ const X = new THREE.Vector3(1, 0, 0), Y = new THREE.Vector3(0, 1, 0);
26
+ const clamp01 = (x) => Math.min(1, Math.max(0, x)), lerp = (a, b, k) => a + (b - a) * k, damp = THREE.MathUtils.damp;
27
+ const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
28
+
29
+ class Spring {
30
+ constructor(value = 0, { stiffness = 220, zeta = 0.7 } = {}) { this.x = value; this.v = 0; this.target = value; this.set(stiffness, zeta); }
31
+ set(stiffness, zeta) { this.k = stiffness; this.c = 2 * zeta * Math.sqrt(stiffness); return this; }
32
+ accel() { return -this.k * (this.x - this.target) - this.c * this.v; }
33
+ step(dt) {
34
+ const n = Math.max(1, Math.ceil(dt * 240)), h = dt / n;
35
+ for (let i = 0; i < n; i++) { this.v += (-this.k * (this.x - this.target) - this.c * this.v) * h; this.x += this.v * h; }
36
+ return this.x;
37
+ }
38
+ }
39
+
40
+ const VERT_UV = 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }';
41
+ const VERT_W = 'varying vec3 vW; void main(){ vW = (modelMatrix * vec4(position, 1.0)).xyz; gl_Position = projectionMatrix * viewMatrix * vec4(vW, 1.0); }';
42
+ function softbox(w, h, pos, rgb, k, soft = 0.22) {
43
+ const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.ShaderMaterial({
44
+ uniforms: { uColor: { value: new THREE.Color(...rgb).multiplyScalar(k) }, uSoft: { value: soft } },
45
+ vertexShader: VERT_UV,
46
+ fragmentShader: 'uniform vec3 uColor; uniform float uSoft; varying vec2 vUv; void main(){ vec2 q = abs(vUv - 0.5) * 2.0; gl_FragColor = vec4(uColor * smoothstep(1.0, 1.0 - uSoft, q.x) * smoothstep(1.0, 1.0 - uSoft, q.y), 1.0); }',
47
+ side: THREE.DoubleSide
48
+ }));
49
+ m.position.set(...pos); m.lookAt(0, 0.5, 0); return m;
50
+ }
51
+ function studio(renderer, base, boxes) {
52
+ const env = new THREE.Scene();
53
+ env.add(new THREE.Mesh(new THREE.SphereGeometry(30, 64, 32), new THREE.ShaderMaterial({
54
+ side: THREE.BackSide,
55
+ vertexShader: 'varying vec3 vP; void main(){ vP = normalize(position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',
56
+ fragmentShader: 'varying vec3 vP; void main(){ vec3 d = normalize(vP); vec3 c = vec3(0.0); ' + base + ' gl_FragColor = vec4(c, 1.0); }'
57
+ })));
58
+ boxes.forEach((b) => env.add(softbox(...b)));
59
+ const pm = new THREE.PMREMGenerator(renderer);
60
+ const t = pm.fromScene(env, 0).texture; pm.dispose();
61
+ env.traverse((o) => { if (o.isMesh) { o.geometry.dispose(); o.material.dispose(); } });
62
+ return t;
63
+ }
64
+ const GLASS_ROOM = ['c += vec3(0.004, 0.005, 0.011) + vec3(0.016, 0.017, 0.03) * smoothstep(-0.2, 1.0, d.y);', [
65
+ [8, 5, [-5, 6, 5], [1, 1, 1], 2.8, 0.5],
66
+ [4.5, 4.5, [0.5, 9, -1], [1, 1, 1], 1.3, 0.5],
67
+ [3.4, 7, [-8, -0.5, 1.5], [0.24, 0.46, 1], 7.5, 0.5],
68
+ [3.4, 8, [-6, 1.5, -5], [0.62, 0.32, 1], 7.5, 0.5],
69
+ [3, 7, [7, 1.5, 2], [0.4, 0.42, 1], 6.0, 0.5],
70
+ [8, 1.8, [1, 4, -8], [0.86, 0.86, 1], 1.4, 0.5],
71
+ [10, 10, [0, -7, 0], [0.42, 0.3, 0.95], 2.0, 0.6],
72
+ [2.8, 5, [-4, -3, 4], [0.3, 0.45, 1], 6.0, 0.5]
73
+ ]];
74
+
75
+ // one stage: renderer, the glass's studio, a backdrop only the glass sees, the floor and its contact shadow, the framing
76
+ // and the loop
77
+ function makeStage(container, o) {
78
+ const canvas = document.createElement('canvas');
79
+ canvas.style.cssText = 'position:absolute;inset:0;display:block;width:100%;height:100%;outline:none;touch-action:none';
80
+ canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', o.label);
81
+ let renderer;
82
+ try {
83
+ renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance' });
84
+ if (!renderer.getContext()) throw new Error('no context');
85
+ } catch (e) { container.classList.add('no-gl'); return null; }
86
+ container.appendChild(canvas);
87
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 1.75));
88
+ renderer.setClearColor(0x000000, 0);
89
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
90
+ renderer.toneMapping = THREE.NeutralToneMapping;
91
+ renderer.toneMappingExposure = 1.0;
92
+ const scene = new THREE.Scene(), camera = new THREE.PerspectiveCamera(30, 1, 0.05, 80);
93
+ const glassEnv = studio(renderer, ...GLASS_ROOM);
94
+ scene.environment = glassEnv;
95
+
96
+ // the stage's own ground (the CSS gradient of .stage), drawn first so the canvas is opaque. The floor's light is added
97
+ // with alpha 0, and WebKit drops colour where alpha is 0: on a transparent canvas Safari showed only what lay under the shadow
98
+ const ground = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({
99
+ uniforms: { uRes: { value: new THREE.Vector2(1, 1) } },
100
+ vertexShader: 'void main(){ gl_Position = vec4(position.xy, 0.0, 1.0); }',
101
+ fragmentShader: `uniform vec2 uRes;
102
+ void main() {
103
+ vec2 uv = gl_FragCoord.xy / uRes;
104
+ float d = length(vec2((uv.x - 0.5) / 1.2, 1.0 - uv.y)); // radial-gradient(120% 100% at 50% 0%, a, b 70%)
105
+ vec3 c = mix(vec3(21.0, 22.0, 26.0), vec3(10.0, 11.0, 12.0), clamp(d / 0.7, 0.0, 1.0)) / 255.0;
106
+ float n = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5;
107
+ gl_FragColor = vec4(c + n / 255.0, 1.0);
108
+ }`,
109
+ depthTest: false, depthWrite: false
110
+ }));
111
+ ground.frustumCulled = false; ground.renderOrder = -20;
112
+ ground.onBeforeRender = (r) => { ground.material.colorWrite = r.getRenderTarget() === null; };
113
+ scene.add(ground);
114
+
115
+ const backdrop = new THREE.Mesh(new THREE.PlaneGeometry(14, 10), new THREE.ShaderMaterial({
116
+ uniforms: { uBack: { value: new THREE.Vector4(...(o.backdrop || [0.5, 1.05, 1.3]), 0) } }, // its centre height, height, width, centre x
117
+ vertexShader: VERT_W,
118
+ fragmentShader: `uniform vec4 uBack; varying vec3 vW;
119
+ void main() {
120
+ float s = (vW.x - uBack.w) / uBack.z, t = (vW.y - uBack.x) / uBack.y;
121
+ vec3 c = vec3(0.37, 0.37, 0.40) * (0.62 + 0.75 * smoothstep(-0.6, 1.0, t));
122
+ c = mix(c, vec3(0.52, 0.32, 0.95), smoothstep(-0.45, -0.95, s) * 0.7);
123
+ c = mix(c, vec3(0.26, 0.44, 1.0), smoothstep(-0.5, -0.95, s) * smoothstep(0.0, -0.7, t) * 0.8);
124
+ c = mix(c, vec3(0.34, 0.40, 0.85), smoothstep(0.55, 1.0, s) * 0.45);
125
+ c *= mix(0.22, 1.0, smoothstep(-0.95, -0.25, t));
126
+ gl_FragColor = vec4(c, 1.0);
127
+ }`,
128
+ depthWrite: false
129
+ }));
130
+ backdrop.position.set(0, 0.5, -2.8); backdrop.renderOrder = -10;
131
+ backdrop.onBeforeRender = (r) => { backdrop.material.colorWrite = r.getRenderTarget() !== null; };
132
+ scene.add(backdrop);
133
+
134
+ // satin frosted glass under a glossy coat; clear where the glass is solid (per-vertex aClear, 0 when absent)
135
+ const glass = new THREE.MeshPhysicalMaterial({
136
+ color: 0xffffff, metalness: 0, roughness: 0.19, transmission: 1, thickness: 0.22, ior: 1.5, dispersion: 0.6,
137
+ clearcoat: 1, clearcoatRoughness: 0.04, specularIntensity: 0.6, envMap: glassEnv, side: THREE.DoubleSide
138
+ });
139
+ glass.onBeforeCompile = (sh) => {
140
+ sh.vertexShader = sh.vertexShader.replace('#include <common>', '#include <common>\nattribute float aClear;\nvarying float vClear;')
141
+ .replace('#include <begin_vertex>', '#include <begin_vertex>\nvClear = aClear;');
142
+ sh.fragmentShader = sh.fragmentShader.replace('#include <common>', '#include <common>\nvarying float vClear;')
143
+ .replace('#include <roughnessmap_fragment>', '#include <roughnessmap_fragment>\nroughnessFactor = mix(roughnessFactor, 0.07, vClear);');
144
+ };
145
+ glass.customProgramCacheKey = () => 'glass-kit';
146
+ const notInGlass = (m) => { m.onBeforeRender = (r) => { m.material.colorWrite = r.getRenderTarget() === null; }; };
147
+
148
+ // the floor: a faint cool pool, the contact shadow and the violet light the glass gathers where it touches
149
+ const SH = { size: o.shadow || 2.6, far: 0.6, res: 256 };
150
+ const pool = new THREE.Mesh(new THREE.PlaneGeometry(SH.size, SH.size), new THREE.ShaderMaterial({
151
+ vertexShader: VERT_UV,
152
+ fragmentShader: `varying vec2 vUv;
153
+ void main() {
154
+ float a = 0.06 * pow(max(0.0, 1.0 - length(vUv - 0.5) * 2.0), 1.7);
155
+ float n = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5;
156
+ gl_FragColor = vec4(vec3(0.82, 0.8, 1.0) * a + n / 255.0 * step(0.002, a), 0.0);
157
+ }`,
158
+ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, premultipliedAlpha: true
159
+ }));
160
+ pool.rotation.x = -Math.PI / 2; pool.position.y = -0.003; pool.renderOrder = 0; scene.add(pool);
161
+ const shadowRT = new THREE.WebGLRenderTarget(SH.res, SH.res), blurRT = new THREE.WebGLRenderTarget(SH.res, SH.res);
162
+ shadowRT.texture.generateMipmaps = blurRT.texture.generateMipmaps = false;
163
+ const shadowCam = new THREE.OrthographicCamera(-SH.size / 2, SH.size / 2, SH.size / 2, -SH.size / 2, 0, SH.far);
164
+ shadowCam.rotation.x = Math.PI / 2; shadowCam.position.y = -0.005; shadowCam.layers.set(1);
165
+ const casterMat = new THREE.ShaderMaterial({
166
+ vertexShader: 'varying float vD; void main(){ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); vD = gl_Position.z / gl_Position.w * 0.5 + 0.5; }',
167
+ fragmentShader: 'varying float vD; void main(){ float h = clamp(vD, 0.0, 1.0); gl_FragColor = vec4(1.0 - smoothstep(0.0, 0.09, h), 0.0, 0.0, pow(1.0 - h, 1.8)); }',
168
+ side: THREE.DoubleSide
169
+ });
170
+ const blurMat = (dx, dy) => new THREE.ShaderMaterial({
171
+ uniforms: { tDiffuse: { value: null }, uStep: { value: 0 } },
172
+ vertexShader: 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }',
173
+ fragmentShader: `uniform sampler2D tDiffuse; uniform float uStep; varying vec2 vUv;
174
+ void main() {
175
+ vec2 d = vec2(${dx.toFixed(1)}, ${dy.toFixed(1)}) * uStep; vec4 s = vec4(0.0);
176
+ s += texture2D(tDiffuse, vUv - 4.0 * d) * 0.051; s += texture2D(tDiffuse, vUv - 3.0 * d) * 0.0918;
177
+ s += texture2D(tDiffuse, vUv - 2.0 * d) * 0.12245; s += texture2D(tDiffuse, vUv - d) * 0.1531;
178
+ s += texture2D(tDiffuse, vUv) * 0.1633;
179
+ s += texture2D(tDiffuse, vUv + d) * 0.1531; s += texture2D(tDiffuse, vUv + 2.0 * d) * 0.12245;
180
+ s += texture2D(tDiffuse, vUv + 3.0 * d) * 0.0918; s += texture2D(tDiffuse, vUv + 4.0 * d) * 0.051;
181
+ gl_FragColor = s;
182
+ }`,
183
+ depthTest: false, depthWrite: false
184
+ });
185
+ const hBlur = blurMat(1, 0), vBlur = blurMat(0, 1);
186
+ const quadCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1), quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2));
187
+ function blurPass(h, v) {
188
+ quad.material = hBlur; hBlur.uniforms.tDiffuse.value = shadowRT.texture; hBlur.uniforms.uStep.value = h / SH.res;
189
+ renderer.setRenderTarget(blurRT); renderer.render(quad, quadCam);
190
+ quad.material = vBlur; vBlur.uniforms.tDiffuse.value = blurRT.texture; vBlur.uniforms.uStep.value = v / SH.res;
191
+ renderer.setRenderTarget(shadowRT); renderer.render(quad, quadCam);
192
+ }
193
+ const floorGeo = new THREE.PlaneGeometry(SH.size, SH.size).rotateX(Math.PI / 2);
194
+ const shadowPlane = new THREE.Mesh(floorGeo, new THREE.ShaderMaterial({
195
+ uniforms: { map: { value: shadowRT.texture } }, vertexShader: VERT_UV,
196
+ fragmentShader: 'uniform sampler2D map; varying vec2 vUv; void main(){ gl_FragColor = vec4(0.0, 0.0, 0.0, texture2D(map, vUv).a * ' + (o.shadowK || 0.8).toFixed(2) + '); }',
197
+ transparent: true, depthWrite: false
198
+ }));
199
+ const glowPlane = new THREE.Mesh(floorGeo, new THREE.ShaderMaterial({
200
+ uniforms: { map: { value: shadowRT.texture } }, vertexShader: VERT_UV,
201
+ fragmentShader: 'uniform sampler2D map; varying vec2 vUv; void main(){ gl_FragColor = vec4(vec3(0.2, 0.15, 0.55) * texture2D(map, vUv).r, 0.0); }',
202
+ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, premultipliedAlpha: true
203
+ }));
204
+ for (const [m, k] of o.glow === false ? [[shadowPlane, 1]] : [[shadowPlane, 1], [glowPlane, 2]]) { m.scale.y = -1; m.position.y = 0.001 * k; m.renderOrder = k; scene.add(m); }
205
+
206
+ const casters = []; // glass meshes: drawn with casterMat for the shadow view
207
+ function renderShadow(lift) {
208
+ casters.forEach((m) => { m.userData.mat = m.material; m.material = casterMat; });
209
+ scene.overrideMaterial = casterMat;
210
+ renderer.setRenderTarget(shadowRT); renderer.render(scene, shadowCam);
211
+ scene.overrideMaterial = null;
212
+ casters.forEach((m) => { m.material = m.userData.mat; });
213
+ const b = 1 + 4 * lift;
214
+ blurPass(2.2 * b, 3.4 * b); blurPass(1.1 * b, 1.7 * b); blurPass(0.5 * b, 0.8 * b);
215
+ renderer.setRenderTarget(null);
216
+ }
217
+
218
+ // framing: a view `w` wide and `h` tall around (0, lookY, 0), seen from `elev` degrees up
219
+ function resize() {
220
+ const w = container.clientWidth, h = container.clientHeight;
221
+ if (!w || !h) return;
222
+ renderer.setSize(w, h, false);
223
+ renderer.getDrawingBufferSize(ground.material.uniforms.uRes.value);
224
+ camera.aspect = w / h;
225
+ const tan = Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));
226
+ const dist = Math.max(o.view[0] / (2 * tan * camera.aspect), o.view[1] / (2 * tan)), el = THREE.MathUtils.degToRad(o.elev);
227
+ camera.position.set(0, o.lookY + Math.sin(el) * dist, (o.lookZ || 0) + Math.cos(el) * dist);
228
+ camera.lookAt(0, o.lookY, o.lookZ || 0);
229
+ camera.updateProjectionMatrix();
230
+ if (stage.onResize) stage.onResize();
231
+ }
232
+ const ro = new ResizeObserver(resize); ro.observe(container);
233
+
234
+ // the loop: dt-based, paused off screen or in a hidden tab. With o.manual nothing runs on its own: stage.tick(dt, now)
235
+ // moves it a step (how the library films it, frame by frame)
236
+ let visible = false, prev = performance.now(), raf = 0, dead = false, io = null;
237
+ function start(update) {
238
+ io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible && stage.onVisible) stage.onVisible(); }, { threshold: 0.25 });
239
+ io.observe(container);
240
+ resize();
241
+ if (o.manual) { stage.tick = update; return; }
242
+ const frame = (now) => {
243
+ if (dead) return;
244
+ raf = requestAnimationFrame(frame);
245
+ const dt = Math.min(0.1, (now - prev) / 1000); prev = now;
246
+ if (!visible || document.hidden) return;
247
+ update(dt, now);
248
+ };
249
+ raf = requestAnimationFrame(frame);
250
+ }
251
+ function destroy() {
252
+ dead = true; cancelAnimationFrame(raf); if (io) io.disconnect(); ro.disconnect();
253
+ scene.traverse((m) => { if (m.isMesh) { m.geometry.dispose(); [].concat(m.material).forEach((x) => x.dispose()); } });
254
+ glassEnv.dispose(); shadowRT.dispose(); blurRT.dispose(); casterMat.dispose(); hBlur.dispose(); vBlur.dispose();
255
+ renderer.dispose(); renderer.forceContextLoss(); canvas.remove();
256
+ }
257
+ const shadowAt = (x, z) => { shadowCam.position.x = x; shadowCam.position.z = z; for (const m of [pool, shadowPlane, glowPlane]) { m.position.x = x; m.position.z = z; } };
258
+ const stage = { renderer, scene, camera, canvas, glass, notInGlass, destroy, casters, renderShadow, start, onResize: null, onVisible: null, backdrop: backdrop.material.uniforms.uBack.value,
259
+ backdropMesh: backdrop, res: ground.material.uniforms.uRes.value, shadowAt,
260
+ draw() { renderer.render(scene, camera); } };
261
+ return stage;
262
+ }
263
+
264
+ // a floor ring: what the glass leaves on the floor when it lands (two violet ripples) and, for the pin, the target it aims at
265
+ function ringMaterial() {
266
+ return new THREE.ShaderMaterial({
267
+ uniforms: {
268
+ uRip: { value: new THREE.Vector3(0, 0, -1) }, // xz centre, time since the impact (-1: none)
269
+ uRipK: { value: 1 }, // how hard it landed
270
+ uMark: { value: new THREE.Vector4(0, 0, 0.1, 0) },// xz centre, radius, opacity
271
+ uSpot: { value: new THREE.Vector3(0, 0, 0) }, // xz centre, strength: the light the glass gathers at its tip
272
+ uHov: { value: new THREE.Vector3(0, 0, 0) }, // xz centre, strength: the curb under the pointer
273
+ uScale: { value: 1 } // the pin's scale: its marks on the floor scale with it
274
+ },
275
+ vertexShader: VERT_W,
276
+ fragmentShader: `uniform vec3 uRip; uniform float uRipK; uniform vec4 uMark; uniform vec3 uSpot, uHov; uniform float uScale; varying vec3 vW;
277
+ float ring(float d, float r, float w) { return smoothstep(w, 0.0, abs(d - r)); }
278
+ void main() {
279
+ vec3 c = vec3(0.0);
280
+ float dm = distance(vW.xz, uMark.xy) / uScale;
281
+ c += vec3(0.62, 0.6, 0.95) * 0.32 * uMark.w * (ring(dm, uMark.z, 0.011) + 0.35 * ring(dm, uMark.z * 0.42, 0.008));
282
+ if (uRip.z >= 0.0) {
283
+ float d = distance(vW.xz, uRip.xy) / uScale;
284
+ for (int k = 0; k < 2; k++) {
285
+ float t = uRip.z - 0.2 * float(k);
286
+ if (t > 0.0) {
287
+ float r = 0.04 + 0.62 * (1.0 - exp(-2.4 * t)), a = exp(-2.0 * t) * smoothstep(0.0, 0.06, t);
288
+ c += vec3(0.42, 0.3, 0.95) * 0.2 * uRipK * a * ring(d, r, 0.018 + 0.05 * t) * (k == 0 ? 1.0 : 0.6);
289
+ }
290
+ }
291
+ }
292
+ float dh = distance(vW.xz, uHov.xy) / uScale;
293
+ c += vec3(0.62, 0.6, 0.95) * 0.3 * uHov.z * (ring(dh, 0.026, 0.006) + smoothstep(0.009, 0.004, dh));
294
+ float ds = distance(vW.xz, uSpot.xy) / uScale;
295
+ c += vec3(0.62, 0.6, 0.95) * 0.14 * uSpot.z * exp(-ds * ds / 0.004);
296
+ float n = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5;
297
+ gl_FragColor = vec4(c + n / 255.0 * step(0.002, c.r + c.b), 0.0);
298
+ }`,
299
+ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, premultipliedAlpha: true
300
+ });
301
+ }
302
+
303
+ // ─── Pin de mapa ─────────────────────────────────────────────────────────────────────────────────────────
304
+ // A teardrop of frosted glass, open through its head like the map pin it is. It flies to the place on a spring, its head
305
+ // leaning with the acceleration like something carried, rises a touch as it aims, falls under gravity and sticks its tip
306
+ // in the map; the glass vibrates about the tip and settles. The map lies under it in the camera's perspective, in focus
307
+ // where the pin is and softly out of focus away from it; the focus follows the pin. A tap on the map sends it there.
308
+ const PIN = { r: 0.34, c: 0.64, rho: 0.028, zs: 0.8, hole: 0.13, fillet: 0.085, tilt: 0.5, hover: 0.42, area: [0.95, 0.62], home: [0.1, 0.05], from: [-2.3, 1.6, -0.75] };
309
+
310
+ // a smooth closed surface from a signed distance: surface nets on a grid, each vertex then pulled onto the surface,
311
+ // normals from the field itself (so the glass shades without facets)
312
+ function sdfGeometry(sdf, min, max, h) {
313
+ const [nx, ny, nz] = [0, 1, 2].map((a) => Math.ceil((max[a] - min[a]) / h) + 1), at = (i, j, k) => i + nx * (j + ny * k);
314
+ const F = new Float32Array(nx * ny * nz);
315
+ for (let k = 0; k < nz; k++) for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) F[at(i, j, k)] = sdf(min[0] + i * h, min[1] + j * h, min[2] + k * h);
316
+ const grad = (x, y, z, e) => [sdf(x + e, y, z) - sdf(x - e, y, z), sdf(x, y + e, z) - sdf(x, y - e, z), sdf(x, y, z + e) - sdf(x, y, z - e)];
317
+ const EDGES = [[0, 1], [2, 3], [4, 5], [6, 7], [0, 2], [1, 3], [4, 6], [5, 7], [0, 4], [1, 5], [2, 6], [3, 7]];
318
+ const cell = new Int32Array(nx * ny * nz).fill(-1), pos = [], nor = [], idx = [], v = new Float32Array(8);
319
+ for (let k = 0; k < nz - 1; k++) for (let j = 0; j < ny - 1; j++) for (let i = 0; i < nx - 1; i++) {
320
+ let inside = 0;
321
+ for (let c = 0; c < 8; c++) { v[c] = F[at(i + (c & 1), j + (c >> 1 & 1), k + (c >> 2))]; if (v[c] < 0) inside++; }
322
+ if (inside === 0 || inside === 8) continue;
323
+ let sx = 0, sy = 0, sz = 0, m = 0;
324
+ for (const [a, b] of EDGES) {
325
+ if ((v[a] < 0) === (v[b] < 0)) continue;
326
+ const t = v[a] / (v[a] - v[b]);
327
+ sx += lerp(a & 1, b & 1, t); sy += lerp(a >> 1 & 1, b >> 1 & 1, t); sz += lerp(a >> 2, b >> 2, t); m++;
328
+ }
329
+ let x = min[0] + (i + sx / m) * h, y = min[1] + (j + sy / m) * h, z = min[2] + (k + sz / m) * h;
330
+ for (let it = 0; it < 4; it++) { // Newton steps onto the zero level
331
+ const d = sdf(x, y, z), g = grad(x, y, z, h * 0.25), gg = g[0] * g[0] + g[1] * g[1] + g[2] * g[2];
332
+ if (gg < 1e-14) break;
333
+ const s = d * h * 0.5 / gg; x -= g[0] * s; y -= g[1] * s; z -= g[2] * s;
334
+ }
335
+ const g = grad(x, y, z, h * 0.25), gl = Math.hypot(...g) || 1;
336
+ cell[at(i, j, k)] = pos.length / 3; pos.push(x, y, z); nor.push(g[0] / gl, g[1] / gl, g[2] / gl);
337
+ }
338
+ const quad = (a, b, c, d) => { // two triangles, turned to face along the surface normals
339
+ if (a < 0 || b < 0 || c < 0 || d < 0) return;
340
+ const P = (n) => [pos[3 * n], pos[3 * n + 1], pos[3 * n + 2]], [A, B, C] = [P(a), P(b), P(c)];
341
+ const u = [B[0] - A[0], B[1] - A[1], B[2] - A[2]], w = [C[0] - A[0], C[1] - A[1], C[2] - A[2]];
342
+ const n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]];
343
+ if (n[0] * nor[3 * a] + n[1] * nor[3 * a + 1] + n[2] * nor[3 * a + 2] < 0) idx.push(a, c, b, a, d, c); else idx.push(a, b, c, a, c, d);
344
+ };
345
+ for (let k = 1; k < nz - 1; k++) for (let j = 1; j < ny - 1; j++) for (let i = 1; i < nx - 1; i++) {
346
+ const f = F[at(i, j, k)] < 0;
347
+ if (f !== (F[at(i + 1, j, k)] < 0)) quad(cell[at(i, j - 1, k - 1)], cell[at(i, j, k - 1)], cell[at(i, j, k)], cell[at(i, j - 1, k)]);
348
+ if (f !== (F[at(i, j + 1, k)] < 0)) quad(cell[at(i - 1, j, k - 1)], cell[at(i, j, k - 1)], cell[at(i, j, k)], cell[at(i - 1, j, k)]);
349
+ if (f !== (F[at(i, j, k + 1)] < 0)) quad(cell[at(i - 1, j - 1, k)], cell[at(i, j - 1, k)], cell[at(i, j, k)], cell[at(i - 1, j, k)]);
350
+ }
351
+ const g = new THREE.BufferGeometry();
352
+ g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
353
+ g.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3));
354
+ g.setIndex(idx);
355
+ return g;
356
+ }
357
+
358
+ // the pin: a teardrop (head and tip joined by the lines tangent to both), a little flatter front to back, with a round
359
+ // hole through its head along the line of sight and every edge of the hole rounded off
360
+ function pinGeometry() {
361
+ const { r, c, rho, zs, hole, fillet, tilt } = PIN, al = Math.asin(r / c), yc = rho / Math.sin(al), hh = c - yc;
362
+ const b = (rho - r) / hh, a = Math.sqrt(1 - b * b), ay = Math.sin(tilt), az = Math.cos(tilt);
363
+ const sdf = (x, y, z) => {
364
+ const px = Math.hypot(x, z / zs), py = y - yc, k = -b * px + a * py;
365
+ const body = k < 0 ? Math.hypot(px, py) - rho : k > a * hh ? Math.hypot(px, py - hh) - r : a * px + b * py - rho;
366
+ const qy = y - c, t = qy * ay + z * az, cut = hole - Math.hypot(x, qy - t * ay, z - t * az);
367
+ const s = Math.max(fillet - Math.abs(body - cut), 0) / fillet;
368
+ return Math.max(body, cut) + s * s * fillet * 0.25; // smooth subtraction
369
+ };
370
+ const h = 0.0095, e = 3 * h;
371
+ const g = sdfGeometry(sdf, [-r - e, -e, -r * zs - e], [r + e, c + r + e, r * zs + e], h);
372
+ const p = g.attributes.position, clear = new Float32Array(p.count);
373
+ for (let i = 0; i < p.count; i++) { const y = p.getY(i), t = clamp01((0.4 - y) / 0.22); clear[i] = t * t * (3 - 2 * t); } // the solid tip reads clear
374
+ g.setAttribute('aClear', new THREE.BufferAttribute(clear, 1));
375
+ return g;
376
+ }
377
+
378
+ // ─── The city under the pin ──────────────────────────────────────────────────────────────────────────────
379
+ // A real street network, drawn once into two textures (a sharp one where the pin can land, a coarse one out to the far
380
+ // blur) and read back by the pointer, so a click lands on a curb with a real address. A grid turned a little, with
381
+ // blocks of different sizes and two avenues on it (Horizonte and Aurora); a river behind with an avenue on its bank,
382
+ // where the streets end and only the avenues cross, on bridges; every block lined with buildings, some of them a place;
383
+ // a park with a pond and a praça. The names are invented.
384
+ const CITY = {
385
+ A: 0.28, M: 400, // grid turn; metres per unit, for house numbers
386
+ AV: 0.013, ST: 0.0068, BR: 0.011, SW: 0.006, GAP: 0.0028, RIVER: 0.13, BANK: 0.024, // half widths, sidewalk, gaps
387
+ near: [-1.75, -1.95, 3.5, 2048], far: [-6.5, -9.5, 13, 1024], // texture squares: x0, z0, size, pixels
388
+ area: [1.0, -1.05, 0.62] // where a click may land: |x|, and z from, to
389
+ };
390
+ const NAMES = {
391
+ col: ['Rua das Acácias', 'Rua Jasmim', 'Rua das Camélias', 'Rua Lavanda', 'Rua Magnólia', 'Rua das Hortênsias', 'Rua Alecrim', 'Rua Dália', 'Rua das Orquídeas', 'Rua Gardênia', 'Rua Begônia', 'Rua Azaleia', 'Rua dos Lírios', 'Rua Violeta'],
392
+ row: ['Rua Ametista', 'Rua Turmalina', 'Rua Safira', 'Rua Ágata', 'Rua Topázio', 'Rua Quartzo', 'Rua Ônix', 'Rua Citrino', 'Rua Jade', 'Rua Granada', 'Rua Opala', 'Rua Âmbar', 'Rua Cristal', 'Rua Esmeralda'],
393
+ avCol: 'Avenida Horizonte', avRow: 'Avenida Aurora', bank: 'Avenida Beira-Rio', farBank: 'Rua da Margem', river: 'Rio Claro',
394
+ parks: ['Parque das Luzes', 'Praça Íris'], hoods: ['Alto da Aurora', 'Vila Opala', 'Jardim Cristal', 'Vila Âmbar'], farHood: 'Vila Ribeira',
395
+ kind: ['Café', 'Padaria', 'Livraria', 'Galeria', 'Ateliê', 'Floricultura', 'Mercado', 'Farmácia'],
396
+ place: ['Lume', 'Prisma', 'Íris', 'Aurora', 'Safira', 'Jade', 'Ônix', 'Lavanda', 'Cristal', 'Quartzo', 'Opala', 'Âmbar']
397
+ };
398
+
399
+ const mod = (n, m) => ((n % m) + m) % m;
400
+
401
+ function buildCity() {
402
+ const C = CITY, ca = Math.cos(C.A), sa = Math.sin(C.A);
403
+ let seed = 11; const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
404
+ const rngOf = (i, j) => { let s = (((i * 73856093) ^ (j * 19349663)) >>> 0) % 2147483646 + 1; return () => (s = (s * 16807) % 2147483647) / 2147483647; };
405
+ const toQ = (x, z) => [ca * x - sa * z, sa * x + ca * z], toW = (u, v) => [ca * u + sa * v, -sa * u + ca * v];
406
+ const lines = (lo, hi, R) => { const a = [0]; for (let x = 0; x < R;) a.push(x += lerp(lo, hi, rnd())); for (let x = 0; x > -R;) a.unshift(x -= lerp(lo, hi, rnd())); return a; };
407
+ const X = lines(0.19, 0.31, 11.5), Y = lines(0.13, 0.21, 11.5), I0 = X.indexOf(0), J0 = Y.indexOf(0);
408
+ const zr = (x) => -1.25 - 0.22 * x + 0.1 * Math.sin(1.4 * x + 0.6);
409
+ const bank = (x, s) => zr(x) + s * (C.RIVER + C.BANK); // the bank roads' centre lines: s = 1 near side, -1 far side
410
+ const land = (x, z, m = 0) => z > bank(x, 1) + m || z < bank(x, -1) - m;
411
+ const hwC = (i) => (i === I0 ? C.AV : C.ST), hwR = (j) => (j === J0 ? C.AV : C.ST);
412
+ const cut = new Set(); // streets left out: c{i},{j} column i between rows j and j+1; r{j},{i}
413
+ for (let i = 0; i < X.length; i++) for (let j = 0; j < Y.length - 1; j++) if (i !== I0 && rnd() < 0.1) cut.add(`c${i},${j}`);
414
+ for (let j = 0; j < Y.length; j++) for (let i = 0; i < X.length - 1; i++) if (j !== J0 && rnd() < 0.1) cut.add(`r${j},${i}`);
415
+ const cellOf = (x, z) => { const [u, v] = toQ(x, z); let i = 0, j = 0; while (X[i + 1] <= u) i++; while (Y[j + 1] <= v) j++; return [i, j]; };
416
+ const range = (A, lo, hi) => { let a = 0, b = A.length - 2; while (a < b && A[a + 1] < lo) a++; while (b > a && A[b] > hi) b--; return [a, b]; };
417
+ const qBounds = (x0, z0, s) => { const c = [[x0, z0], [x0 + s, z0], [x0, z0 + s], [x0 + s, z0 + s]].map(([x, z]) => toQ(x, z)); return [Math.min(...c.map((q) => q[0])), Math.max(...c.map((q) => q[0])), Math.min(...c.map((q) => q[1])), Math.max(...c.map((q) => q[1]))]; };
418
+
419
+ const parks = [];
420
+ const addPark = (x, z, n, name, pond) => { // n × n cells, the streets inside it left out, the avenues kept outside
421
+ let [i, j] = cellOf(x, z);
422
+ if (n > 1 && i + 1 === I0) i++; if (n > 1 && j + 1 === J0) j++;
423
+ for (let a = i; a < i + n; a++) for (let b = j; b < j + n; b++) { if (a > i) cut.add(`c${a},${b}`); if (b > j) cut.add(`r${b},${a}`); }
424
+ parks.push({ i: [i, i + n - 1], j: [j, j + n - 1], name, pond });
425
+ };
426
+ addPark(0.75, -0.62, 2, NAMES.parks[0], true); addPark(-0.95, 0.5, 1, NAMES.parks[1], false);
427
+ const parkOf = (i, j) => parks.findIndex((p) => i >= p.i[0] && i <= p.i[1] && j >= p.j[0] && j <= p.j[1]);
428
+ const block = (i, j) => { // the built part of cell (i, j), inside the sidewalks of the streets it has
429
+ const L = !cut.has(`c${i},${j}`), R = !cut.has(`c${i + 1},${j}`), B = !cut.has(`r${j},${i}`), T = !cut.has(`r${j + 1},${i}`);
430
+ return { i, j, L, R, B, T, x0: X[i] + (L ? hwC(i) + C.SW : 0), x1: X[i + 1] - (R ? hwC(i + 1) + C.SW : 0), y0: Y[j] + (B ? hwR(j) + C.SW : 0), y1: Y[j + 1] - (T ? hwR(j + 1) + C.SW : 0) };
431
+ };
432
+ const parkRect = (p) => { const a = block(p.i[0], p.j[0]), b = block(p.i[1], p.j[1]); return [a.x0, b.x1, a.y0, b.y1]; };
433
+ const lotsOf = (b) => { // lots along each side with a street, or now and then one big building
434
+ const r = rngOf(b.i, b.j), out = [], D = lerp(0.042, 0.058, r()), G = C.GAP;
435
+ if (b.x1 - b.x0 < 0.05 || b.y1 - b.y0 < 0.05) return out;
436
+ if (r() < 0.07) { out.push({ u0: b.x0 + 0.012, u1: b.x1 - 0.012, v0: b.y0 + 0.012, v1: b.y1 - 0.012, t: 0.8 + 0.2 * r() }); return out; }
437
+ const run = (a0, a1, c0, c1, alongU, side) => {
438
+ for (let s = a0; s < a1 - 0.012;) {
439
+ let w = lerp(0.022, 0.05, r()); if (a1 - (s + w) < 0.02) w = a1 - s;
440
+ const t = 0.55 + 0.45 * r();
441
+ out.push(alongU ? { u0: s, u1: s + w, v0: c0, v1: c1, t, side } : { u0: c0, u1: c1, v0: s, v1: s + w, t, side });
442
+ s += w + G;
443
+ }
444
+ };
445
+ if (b.B) run(b.x0, b.x1, b.y0, b.y0 + D, true, 'B');
446
+ if (b.T) run(b.x0, b.x1, b.y1 - D, b.y1, true, 'T');
447
+ const va = b.B ? b.y0 + D + G : b.y0, vb = b.T ? b.y1 - D - G : b.y1;
448
+ if (b.L) run(va, vb, b.x0, b.x0 + D, false, 'L');
449
+ if (b.R) run(va, vb, b.x1 - D, b.x1, false, 'R');
450
+ return out;
451
+ };
452
+
453
+ // the streets near the pin, in world units, for snapping, numbering, lighting up and naming. Each piece is a straight
454
+ // stretch on land; s0, s1: where it runs along its street, in units (house numbers count M metres a unit from there)
455
+ const [nu0, nu1, nv0, nv1] = qBounds(C.near[0], C.near[1], C.near[2]), NI = range(X, nu0, nu1), NJ = range(Y, nv0, nv1);
456
+ const streets = [];
457
+ const onLand = (a, b, s0, s1, out) => { // split a straight stretch where it meets the river's bank roads
458
+ const n = 24, P = (t) => [lerp(a[0], b[0], t), lerp(a[1], b[1], t)], ok = (t) => { const [x, z] = P(t); return land(x, z); };
459
+ let t0 = ok(0) ? 0 : null;
460
+ for (let k = 1; k <= n; k++) {
461
+ const t = k / n, o = ok(t);
462
+ if ((t0 !== null) !== o) { // a crossing: find it
463
+ let lo = (k - 1) / n, hi = t; for (let it = 0; it < 14; it++) { const m = (lo + hi) / 2; if (ok(m) === ok(lo)) lo = m; else hi = m; }
464
+ const tc = (lo + hi) / 2;
465
+ if (t0 !== null) { out.push({ a: P(t0), b: P(tc), s0: lerp(s0, s1, t0), s1: lerp(s0, s1, tc) }); t0 = null; } else t0 = tc;
466
+ }
467
+ }
468
+ if (t0 !== null) out.push({ a: P(t0), b: P(1), s0: lerp(s0, s1, t0), s1 });
469
+ };
470
+ for (let i = NI[0]; i <= NI[1] + 1; i++) {
471
+ const st = { id: 'c' + i, name: i === I0 ? NAMES.avCol : NAMES.col[mod(i, NAMES.col.length)], hw: hwC(i), kind: i === I0 ? 'av' : 'st', pieces: [] };
472
+ for (let j = NJ[0]; j <= NJ[1]; j++) if (!cut.has(`c${i},${j}`)) onLand(toW(X[i], Y[j]), toW(X[i], Y[j + 1]), Y[j] + 6, Y[j + 1] + 6, st.pieces);
473
+ if (st.pieces.length) streets.push(st);
474
+ }
475
+ for (let j = NJ[0]; j <= NJ[1] + 1; j++) {
476
+ const st = { id: 'r' + j, name: j === J0 ? NAMES.avRow : NAMES.row[mod(j, NAMES.row.length)], hw: hwR(j), kind: j === J0 ? 'av' : 'st', pieces: [] };
477
+ for (let i = NI[0]; i <= NI[1]; i++) if (!cut.has(`r${j},${i}`)) onLand(toW(X[i], Y[j]), toW(X[i + 1], Y[j]), X[i] + 6, X[i + 1] + 6, st.pieces);
478
+ if (st.pieces.length) streets.push(st);
479
+ }
480
+ for (const s of [1, -1]) {
481
+ const st = { id: s > 0 ? 'bn' : 'bf', name: s > 0 ? NAMES.bank : NAMES.farBank, hw: C.BR, kind: 'bank', pieces: [] };
482
+ let acc = 8;
483
+ for (let x = C.near[0]; x < C.near[0] + C.near[2]; x += 0.05) {
484
+ const a = [x, bank(x, s)], b = [x + 0.05, bank(x + 0.05, s)], L = Math.hypot(b[0] - a[0], b[1] - a[1]);
485
+ st.pieces.push({ a, b, s0: acc, s1: acc + L }); acc += L;
486
+ }
487
+ streets.push(st);
488
+ }
489
+ const hoodOf = (x, z) => { if (z < bank(x, -1)) return NAMES.farHood; const [u, v] = toQ(x, z); return NAMES.hoods[(u >= 0 ? 1 : 0) + (v >= 0 ? 2 : 0)]; };
490
+ function snap(x, z) { // the nearest curb: the nearest street, then out to the curb on the pointer's side
491
+ let best = null, bd = Infinity;
492
+ for (const st of streets) for (const p of st.pieces) {
493
+ const dx = p.b[0] - p.a[0], dz = p.b[1] - p.a[1], L2 = dx * dx + dz * dz || 1e-9;
494
+ const t = clamp01(((x - p.a[0]) * dx + (z - p.a[1]) * dz) / L2), cx = p.a[0] + dx * t, cz = p.a[1] + dz * t;
495
+ const d = Math.hypot(x - cx, z - cz) - st.hw;
496
+ if (d < bd) { bd = d; best = { st, p, t, cx, cz, dx, dz, L: Math.sqrt(L2) }; }
497
+ }
498
+ const { st, p, t, cx, cz, dx, dz, L } = best;
499
+ const side = (x - cx) * -dz + (z - cz) * dx >= 0 ? 1 : -1, nx = -dz / L * side, nz = dx / L * side, off = st.hw + 0.007;
500
+ const m = Math.max(1, Math.round(lerp(p.s0, p.s1, t) * C.M)), num = 2 * Math.round(m / 2) + (side > 0 ? 1 : 0) || 1;
501
+ const sx = cx + nx * off, sz = cz + nz * off;
502
+ return { x: sx, z: sz, street: st, num, address: `${st.name}, ${num}`, hood: hoodOf(sx, sz), d: bd };
503
+ }
504
+
505
+ // the places: one in some blocks near the pin, on the street side of one of its buildings
506
+ const pois = [];
507
+ for (let i = NI[0]; i <= NI[1]; i++) for (let j = NJ[0]; j <= NJ[1]; j++) {
508
+ if (parkOf(i, j) >= 0) continue;
509
+ const b = block(i, j), lots = lotsOf(b).filter((l) => l.side), r = rngOf(j * 7 + 3, i * 13 + 5);
510
+ if (!lots.length || r() > 0.3) continue;
511
+ const l = lots[Math.floor(r() * lots.length)], mu = (l.u0 + l.u1) / 2, mv = (l.v0 + l.v1) / 2;
512
+ const [u, v] = l.side === 'B' ? [mu, l.v0 + 0.011] : l.side === 'T' ? [mu, l.v1 - 0.011] : l.side === 'L' ? [l.u0 + 0.011, mv] : [l.u1 - 0.011, mv];
513
+ const [x, z] = toW(u, v);
514
+ if (!land(x, z, C.BR + C.SW)) continue;
515
+ pois.push({ id: pois.length, u, v, x, z, name: `${NAMES.kind[Math.floor(r() * 8)]} ${NAMES.place[Math.floor(r() * 12)]}`, snap: snap(x, z) });
516
+ }
517
+ return { X, Y, I0, J0, cut, parks, parkOf, parkRect, block, lotsOf, zr, bank, land, toQ, toW, range, qBounds, streets, snap, hoodOf, pois, cellOf };
518
+ }
519
+
520
+ // the city painted into a texture: [x0, z0, size, pixels]; detail: the buildings lot by lot and the places
521
+ function paintCity(city, [x0, z0, size, N], detail) {
522
+ const C = CITY, ca = Math.cos(C.A), sa = Math.sin(C.A), k = N / size, { X, Y, I0, J0 } = city;
523
+ const cv = document.createElement('canvas'); cv.width = cv.height = N;
524
+ const g = cv.getContext('2d');
525
+ const W = () => g.setTransform(k, 0, 0, k, -k * x0, -k * z0), Q = () => g.setTransform(k * ca, -k * sa, k * sa, k * ca, -k * x0, -k * z0);
526
+ const landPath = (m) => { // the land on both sides of the river, m units back from the bank roads' centre lines
527
+ g.beginPath();
528
+ for (let x = x0 - 0.5; x <= x0 + size + 0.5; x += 0.04) g.lineTo(x, city.bank(x, 1) + m);
529
+ g.lineTo(x0 + size + 0.5, z0 + size + 0.5); g.lineTo(x0 - 0.5, z0 + size + 0.5); g.closePath();
530
+ g.moveTo(x0 + size + 0.5, z0 - 0.5); g.lineTo(x0 - 0.5, z0 - 0.5);
531
+ for (let x = x0 - 0.5; x <= x0 + size + 0.5; x += 0.04) g.lineTo(x, city.bank(x, -1) - m);
532
+ g.closePath();
533
+ };
534
+ W(); g.fillStyle = 'rgb(5,5,7)'; g.fillRect(x0, z0, size, size);
535
+ const [u0, u1, v0, v1] = city.qBounds(x0, z0, size), I = city.range(X, u0 - 0.3, u1 + 0.3), J = city.range(Y, v0 - 0.3, v1 + 0.3);
536
+
537
+ // parks: a dark green with paths; the big one has a pond, the praça its diagonals and a fountain
538
+ Q();
539
+ for (const p of city.parks) {
540
+ const [a, b, c, d] = city.parkRect(p), w = b - a, h = d - c, cx = (a + b) / 2, cy = (c + d) / 2;
541
+ g.fillStyle = 'rgb(9,15,14)'; g.beginPath(); g.roundRect(a, c, w, h, 0.012); g.fill();
542
+ g.strokeStyle = 'rgb(21,29,27)'; g.lineWidth = 0.0045; g.lineCap = 'round'; g.beginPath();
543
+ if (p.pond) {
544
+ g.ellipse(cx, cy, w * 0.4, h * 0.38, 0, 0, TAU);
545
+ g.moveTo(a, cy); g.lineTo(cx - w * 0.4, cy); g.moveTo(b, cy); g.lineTo(cx + w * 0.4, cy);
546
+ g.moveTo(cx, c); g.lineTo(cx, cy - h * 0.38); g.moveTo(cx, d); g.lineTo(cx, cy + h * 0.38);
547
+ g.stroke(); g.fillStyle = 'rgb(5,10,22)'; g.beginPath(); g.ellipse(cx, cy, w * 0.27, h * 0.24, 0.2, 0, TAU); g.fill();
548
+ } else {
549
+ g.moveTo(a, c); g.lineTo(b, d); g.moveTo(b, c); g.lineTo(a, d);
550
+ g.moveTo(cx + Math.min(w, h) * 0.3, cy); g.arc(cx, cy, Math.min(w, h) * 0.3, 0, TAU); g.stroke();
551
+ g.fillStyle = 'rgb(5,10,22)'; g.beginPath(); g.arc(cx, cy, Math.min(w, h) * 0.09, 0, TAU); g.fill();
552
+ }
553
+ }
554
+ // buildings, only on land
555
+ g.save(); W(); landPath(C.BR + C.SW); g.clip(); Q();
556
+ for (let i = I[0]; i <= I[1]; i++) for (let j = J[0]; j <= J[1]; j++) {
557
+ if (city.parkOf(i, j) >= 0) continue;
558
+ const b = city.block(i, j);
559
+ if (!detail) { g.fillStyle = 'rgb(14,15,20)'; g.fillRect(b.x0, b.y0, b.x1 - b.x0, b.y1 - b.y0); continue; }
560
+ g.fillStyle = 'rgb(10,10,14)'; g.fillRect(b.x0, b.y0, b.x1 - b.x0, b.y1 - b.y0); // the yards
561
+ for (const l of city.lotsOf(b)) { g.fillStyle = `rgb(${Math.round(13 + 13 * l.t)},${Math.round(14 + 13 * l.t)},${Math.round(19 + 16 * l.t)})`; g.fillRect(l.u0, l.v0, l.u1 - l.u0, l.v1 - l.v0); }
562
+ }
563
+ g.restore();
564
+ // the river
565
+ W(); g.fillStyle = 'rgb(5,9,21)'; g.beginPath();
566
+ for (let x = x0 - 0.5; x <= x0 + size + 0.5; x += 0.04) g.lineTo(x, city.zr(x) + C.RIVER);
567
+ for (let x = x0 + size + 0.5; x >= x0 - 0.5; x -= 0.04) g.lineTo(x, city.zr(x) - C.RIVER);
568
+ g.fill();
569
+ // the streets, up to the bank roads
570
+ g.save(); W(); landPath(0); g.clip(); Q();
571
+ g.strokeStyle = 'rgb(36,38,50)'; g.lineWidth = 2 * C.ST; g.lineCap = 'butt'; g.beginPath();
572
+ for (let i = I[0]; i <= I[1] + 1; i++) if (i !== I0) for (let j = J[0]; j <= J[1]; j++) if (!city.cut.has(`c${i},${j}`)) { g.moveTo(X[i], Y[j]); g.lineTo(X[i], Y[j + 1]); }
573
+ for (let j = J[0]; j <= J[1] + 1; j++) if (j !== J0) for (let i = I[0]; i <= I[1]; i++) if (!city.cut.has(`r${j},${i}`)) { g.moveTo(X[i], Y[j]); g.lineTo(X[i + 1], Y[j]); }
574
+ g.stroke(); g.restore();
575
+ // the bank roads, then the avenues over everything (on bridges across the river)
576
+ W(); g.strokeStyle = 'rgb(50,52,68)'; g.lineWidth = 2 * C.BR; g.lineJoin = 'round';
577
+ for (const s of [1, -1]) { g.beginPath(); for (let x = x0 - 0.5; x <= x0 + size + 0.5; x += 0.04) g.lineTo(x, city.bank(x, s)); g.stroke(); }
578
+ Q(); g.strokeStyle = 'rgb(66,68,90)'; g.lineWidth = 2 * C.AV; g.beginPath();
579
+ g.moveTo(X[I0], Y[J[0]]); g.lineTo(X[I0], Y[J[1] + 1]); g.moveTo(X[I[0]], Y[J0]); g.lineTo(X[I[1] + 1], Y[J0]); g.stroke();
580
+ if (detail) { g.fillStyle = 'rgb(122,118,178)'; for (const p of city.pois) { g.beginPath(); g.arc(p.u, p.v, 0.0085, 0, TAU); g.fill(); } }
581
+
582
+ const tex = new THREE.CanvasTexture(cv);
583
+ tex.flipY = false; tex.generateMipmaps = true; tex.minFilter = THREE.LinearMipmapLinearFilter; tex.anisotropy = 8;
584
+ return tex;
585
+ }
586
+
587
+ // the map: the two textures seen through a lens. The blur grows with the distance from the focus, mostly in depth as a
588
+ // lens does and a little sideways; out of focus each pixel is a disc of taps (a bokeh), sampled from a coarser mip level
589
+ const LENS = `
590
+ uniform vec2 uFocus; uniform vec3 uCam, uFwd, uRight, uBlur;
591
+ float lensBlur(vec3 w) { // gaussian sigma of the blur at w, in world units
592
+ vec3 fp = vec3(uFocus.x, 0.0, uFocus.y);
593
+ float e = length(vec2(dot(w - fp, uFwd), dot(w - fp, uRight) * uBlur.z)) - uBlur.y;
594
+ return uBlur.x * 0.5 * (e + sqrt(e * e + 0.01));
595
+ }`;
596
+ function mapMaterial(near, far) {
597
+ const C = CITY;
598
+ return new THREE.ShaderMaterial({
599
+ uniforms: {
600
+ uFocus: { value: new THREE.Vector2() }, uCam: { value: new THREE.Vector3() }, uFwd: { value: new THREE.Vector3() }, uRight: { value: new THREE.Vector3() },
601
+ uBlur: { value: new THREE.Vector3(0.05, 0.24, 0.5) }, // blur per unit away, the sharp radius, how much sideways counts
602
+ uNear: { value: near }, uFar: { value: far },
603
+ uNR: { value: new THREE.Vector4(C.near[0], C.near[1], C.near[2], C.near[3] / C.near[2]) }, uFR: { value: new THREE.Vector4(C.far[0], C.far[1], C.far[2], C.far[3] / C.far[2]) }
604
+ },
605
+ vertexShader: VERT_W,
606
+ fragmentShader: `${LENS}
607
+ uniform sampler2D uNear, uFar; uniform vec4 uNR, uFR; varying vec3 vW;
608
+ vec3 lens(sampler2D t, vec2 uv, float r) { // r: the disc's radius in texels
609
+ vec3 sharp = texture(t, uv).rgb;
610
+ if (r < 0.75) return sharp;
611
+ vec2 ts = 1.0 / vec2(textureSize(t, 0)); float lod = log2(r * 0.55); vec3 a = vec3(0.0);
612
+ for (int i = 0; i < 16; i++) { float fi = float(i) + 0.5, an = fi * 2.3999632; a += textureLod(t, uv + vec2(cos(an), sin(an)) * sqrt(fi / 16.0) * r * ts, lod).rgb; }
613
+ return mix(sharp, a / 16.0, smoothstep(0.75, 1.6, r));
614
+ }
615
+ void main() {
616
+ vec2 p = vW.xz; float R = 2.0 * lensBlur(vW); // a disc twice the sigma
617
+ vec2 nuv = (p - uNR.xy) / uNR.z, fuv = (p - uFR.xy) / uFR.z;
618
+ float wn = smoothstep(0.0, 0.25, min(min(nuv.x, 1.0 - nuv.x), min(nuv.y, 1.0 - nuv.y)) * uNR.z); // near texture, fading out at its edge
619
+ vec3 c = vec3(0.0);
620
+ if (wn > 0.0) c += lens(uNear, nuv, R * uNR.w) * wn;
621
+ if (wn < 1.0) c += lens(uFar, fuv, R * uFR.w) * (1.0 - wn);
622
+ c *= smoothstep(12.0, 6.5, dot(vW - uCam, uFwd)); // the far map fades into the dark
623
+ float n = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5;
624
+ gl_FragColor = vec4(c + n / 255.0, 0.0);
625
+ }`,
626
+ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, premultipliedAlpha: true
627
+ });
628
+ }
629
+
630
+ // a street lit up under the pointer: a ribbon along its pieces, soft at the edges by the same lens
631
+ function ribbonMaterial(MU) {
632
+ return new THREE.ShaderMaterial({
633
+ uniforms: { uFocus: MU.uFocus, uCam: MU.uCam, uFwd: MU.uFwd, uRight: MU.uRight, uBlur: MU.uBlur, uK: { value: 0 } },
634
+ vertexShader: 'attribute vec2 aAcross; varying vec2 vA; varying vec3 vW; void main(){ vA = aAcross; vW = (modelMatrix * vec4(position, 1.0)).xyz; gl_Position = projectionMatrix * viewMatrix * vec4(vW, 1.0); }',
635
+ fragmentShader: `${LENS}
636
+ uniform float uK; varying vec2 vA; varying vec3 vW;
637
+ float erfA(float x) { float x2 = x * x; return sign(x) * sqrt(1.0 - exp(-x2 * (1.2732395 + 0.147 * x2) / (1.0 + 0.147 * x2))); }
638
+ void main() {
639
+ float s = 1.4142 * max(lensBlur(vW), length(fwidth(vW.xz)) * 0.5);
640
+ float cov = 0.5 * (erfA((vA.x + vA.y) / s) - erfA((vA.x - vA.y) / s));
641
+ gl_FragColor = vec4(vec3(0.23, 0.21, 0.42) * cov * uK, 0.0);
642
+ }`,
643
+ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, premultipliedAlpha: true
644
+ });
645
+ }
646
+ function ribbonGeometry(street) {
647
+ const pos = [], acr = [], idx = [], pad = 0.035, w = street.hw + pad;
648
+ for (const p of street.pieces) {
649
+ const dx = p.b[0] - p.a[0], dz = p.b[1] - p.a[1], L = Math.hypot(dx, dz) || 1, nx = -dz / L * w, nz = dx / L * w, o = pos.length / 3;
650
+ pos.push(p.a[0] + nx, 0, p.a[1] + nz, p.a[0] - nx, 0, p.a[1] - nz, p.b[0] - nx, 0, p.b[1] - nz, p.b[0] + nx, 0, p.b[1] + nz);
651
+ acr.push(w, street.hw, -w, street.hw, -w, street.hw, w, street.hw);
652
+ idx.push(o, o + 1, o + 2, o, o + 2, o + 3);
653
+ }
654
+ const g = new THREE.BufferGeometry();
655
+ g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setAttribute('aAcross', new THREE.Float32BufferAttribute(acr, 2)); g.setIndex(idx);
656
+ return g;
657
+ }
658
+
659
+ // root: the element the piece fills (give it a size; it is made for about 560 × 560). o: { labels: { title, confirm,
660
+ // confirmed, nogl }, label (the canvas's accessible name), autoplay (false: wait for replay()), fonts (false: don't load DM
661
+ // Sans), manual (no clock of its own: the returned tick(dt, now) moves it) }. Events on root: "pinchange" (the pin was sent to
662
+ // an address) and "pinconfirm" (it was confirmed), detail { address, neighbourhood, place }
663
+ export function mountPin(root, o = {}) {
664
+ const L = Object.assign({}, LABELS), container = root;
665
+ for (const k in o.labels || {}) if (o.labels[k] != null) L[k] = o.labels[k]; // a word left out keeps its default
666
+ css(); if (o.fonts !== false) fonts();
667
+ root.classList.add('pin-root');
668
+ const K = makeStage(container, { label: o.label || L.label, view: [2.45, 2.25], elev: 34, lookY: 0.36, lookZ: 0.05, shadow: 3.2, backdrop: [-0.3, 1.2, 1.0], manual: o.manual });
669
+ if (!K) { // no WebGL: say so, quietly, where the map would be
670
+ const n = document.createElement('div'); n.className = 'pin-nogl'; n.textContent = L.nogl; root.appendChild(n);
671
+ return { ready: Promise.resolve(), replay() {}, confirm() {}, tick() {}, get address() { return null; }, destroy() { n.remove(); root.classList.remove('pin-root'); } };
672
+ }
673
+ const { scene } = K, city = buildCity(), A = CITY.area;
674
+ const map = new THREE.Mesh(new THREE.PlaneGeometry(26, 26).rotateX(-Math.PI / 2), mapMaterial(paintCity(city, CITY.near, true), paintCity(city, CITY.far, false)));
675
+ map.position.y = -0.002; map.renderOrder = 0; scene.add(map);
676
+ const MU = map.material.uniforms, BACK_Y = K.backdrop.x;
677
+ const behind = (x, y, z, out) => { // where the line from the camera through the pin's head meets the backdrop
678
+ const C = K.camera.position, t = (-2.8 - C.z) / (z - C.z);
679
+ return out.set(C.x + (x - C.x) * t, C.y + (y + PIN.c - C.y) * t);
680
+ };
681
+ const _b = new THREE.Vector2(), _b0 = new THREE.Vector2();
682
+ const rings = new THREE.Mesh(new THREE.PlaneGeometry(3.6, 3.6).rotateX(-Math.PI / 2), ringMaterial());
683
+ rings.position.y = 0.003; rings.renderOrder = 3; scene.add(rings);
684
+ const U = rings.material.uniforms;
685
+
686
+ const body = new THREE.Mesh(pinGeometry(), K.glass); body.renderOrder = 2; body.layers.enable(1);
687
+ const pivot = new THREE.Group(); pivot.add(body); scene.add(pivot);
688
+ K.casters.push(body);
689
+
690
+ // the channels, each on its own spring: where it goes (x, z), its height, its lean (how far the head leads, x and z)
691
+ const sx = new Spring(0, { stiffness: 30, zeta: 0.9 }), sz = new Spring(0, { stiffness: 30, zeta: 0.9 }), sy = new Spring(0, { stiffness: 55, zeta: 0.85 });
692
+ const lx = new Spring(0), lz = new Spring(0);
693
+ const FLY = [110, 0.5], STUCK = [380, 0.09], FOCUS = [1.5, 1.1];
694
+ let state = 'wait', vy = 0, aim = 0, rip = -1, ripX = 0, ripZ = 0, ripK = 1, mark = 0, markR = 0.2, spot = 0, sway = 0;
695
+ const dir = new THREE.Vector2(0.8, 0.6), _v = new THREE.Vector2(), goal = new THREE.Vector2(), focus = new THREE.Vector2();
696
+ const focusAt = (x, z, k = 1) => { // the focus pulls towards (x, z): the mark, as a focus puller knows where it lands
697
+ focus.x = lerp(focus.x, THREE.MathUtils.clamp(x, -FOCUS[0], FOCUS[0]), k); focus.y = lerp(focus.y, THREE.MathUtils.clamp(z, -FOCUS[1], FOCUS[1]), k);
698
+ };
699
+ function reset() {
700
+ sx.x = sx.target = PIN.from[0]; sy.x = PIN.from[1]; sz.x = sz.target = PIN.from[2]; sx.v = sy.v = sz.v = 0;
701
+ lx.x = lx.v = lz.x = lz.v = 0; lx.set(...FLY); lz.set(...FLY);
702
+ rip = -1; mark = 0; spot = 0; state = 'wait';
703
+ }
704
+ function goTo(x, z) {
705
+ if (reduce) { // no flight: it is simply there, and so is the focus
706
+ sx.x = sx.target = x; sz.x = sz.target = z; goal.set(x, z); sy.x = 0; state = 'stuck'; rip = -1; spot = 1; focusAt(x, z); U.uMark.value.set(x, z, 0.07, 0); return;
707
+ }
708
+ _v.set(x - sx.x, z - sz.x); if (_v.lengthSq() > 1e-4) dir.copy(_v).normalize();
709
+ goal.set(x, z); sy.target = PIN.hover; // it lifts first, then goes (see step)
710
+ if (state === 'drop') sy.v = vy; // caught mid-fall: the height spring takes its speed
711
+ if (state !== 'fly' && state !== 'leave') { lx.set(...FLY); lz.set(...FLY); }
712
+ state = 'fly'; markR = Math.max(markR, 0.16);
713
+ }
714
+ function leave() { // replay: it pulls its tip out and flies off the way it came, then comes back
715
+ goal.set(PIN.from[0], PIN.from[2]); sy.target = PIN.from[1];
716
+ if (state === 'drop') sy.v = vy;
717
+ if (state !== 'fly') { lx.set(...FLY); lz.set(...FLY); }
718
+ state = 'leave';
719
+ }
720
+ function impact(v) {
721
+ state = 'stuck'; sy.x = 0; vy = 0;
722
+ lx.set(...STUCK); lz.set(...STUCK);
723
+ const k = Math.min(1.9, 0.8 * v); // the knock: the glass springs about its tip, along its way in
724
+ lx.v += dir.x * k; lz.v += dir.y * k;
725
+ rip = 0; ripX = sx.x; ripZ = sz.x; ripK = clamp01(v / 2.2);
726
+ }
727
+ const frustum = new THREE.Frustum(), _m = new THREE.Matrix4(), bound = new THREE.Sphere(new THREE.Vector3(), 0.55);
728
+ function inView() {
729
+ K.camera.updateMatrixWorld(); _m.multiplyMatrices(K.camera.projectionMatrix, K.camera.matrixWorldInverse); frustum.setFromProjectionMatrix(_m);
730
+ bound.center.set(sx.x, sy.x + 0.5, sz.x); return frustum.intersectsSphere(bound);
731
+ }
732
+ function step(dt, now) {
733
+ if (state === 'fly' || state === 'aim' || state === 'leave') {
734
+ if (sy.x > 0.12) { sx.target = goal.x; sz.target = goal.y; } // the tip clears the map before it travels
735
+ sx.step(dt); sz.step(dt); sy.step(dt);
736
+ lx.target = THREE.MathUtils.clamp(sx.accel() * 0.05, -0.3, 0.3); // the head leans with the acceleration
737
+ lz.target = THREE.MathUtils.clamp(sz.accel() * 0.05, -0.3, 0.3);
738
+ const dist = Math.hypot(sx.x - goal.x, sz.x - goal.y), sp = Math.hypot(sx.v, sz.v);
739
+ if (state === 'fly' && dist < 0.012 && sp < 0.07 && Math.abs(sy.x - PIN.hover) < 0.03 && Math.abs(sy.v) < 0.12) { state = 'aim'; aim = 0; sy.target = PIN.hover + 0.07; }
740
+ if (state === 'aim' && (aim += dt) > 0.15) { state = 'drop'; vy = sy.v; }
741
+ if (state === 'leave' && (!inView() || dist < 0.05)) { reset(); pick(...PIN.home); } // out of the frame: it comes back in
742
+ } else if (state === 'drop') {
743
+ sx.step(dt); sz.step(dt);
744
+ lx.target = lz.target = 0;
745
+ vy -= G * dt; sy.x += vy * dt;
746
+ if (sy.x <= 0) impact(-vy);
747
+ }
748
+ lx.step(dt); lz.step(dt);
749
+ if (rip >= 0) rip += dt;
750
+ const flying = state === 'fly' || state === 'aim' || state === 'drop';
751
+ mark = damp(mark, flying ? 1 : 0, flying ? 8 : 10, dt);
752
+ const dist = Math.hypot(sx.x - goal.x, sz.x - goal.y);
753
+ markR = damp(markR, 0.075 + 0.11 * clamp01(dist / 0.7), 5, dt);
754
+ spot = damp(spot, state === 'stuck' ? 1 : 0, state === 'stuck' ? 3 : 8, dt);
755
+ sway = damp(sway, state === 'stuck' && !reduce ? 1 : 0, 1.2, dt);
756
+ if (!reduce && state !== 'leave' && state !== 'wait') focusAt(goal.x, goal.y, 1 - Math.exp(-3.2 * dt)); // it racks to the new mark as the pin flies
757
+ ready(state === 'stuck');
758
+ }
759
+ function render(now) {
760
+ const swx = sway * 0.022 * Math.sin(now / 3900), swz = sway * 0.016 * Math.sin(now / 5300 + 1.3);
761
+ pivot.position.set(sx.x, sy.x, sz.x);
762
+ pivot.rotation.set(lz.x + swz, 0, -(lx.x + swx), 'XZY'); // the head leads by (lx, lz) radians
763
+ U.uMark.value.set(goal.x, goal.y, markR, mark);
764
+ U.uRip.value.set(ripX, ripZ, rip); U.uRipK.value = ripK;
765
+ U.uSpot.value.set(sx.x, sz.x, spot);
766
+ U.uHov.value.set(hv.x, hv.z, hv.k);
767
+ behind(sx.x, sy.x, sz.x, _b); // what the glass sees behind it moves with it, so it keeps its tone anywhere
768
+ K.backdrop.x = BACK_Y + _b.y - _b0.y; K.backdrop.w = 0.85 * (_b.x - _b0.x);
769
+ MU.uFocus.value.copy(focus); MU.uCam.value.copy(K.camera.position);
770
+ K.camera.getWorldDirection(MU.uFwd.value); MU.uRight.value.crossVectors(MU.uFwd.value, Y).normalize();
771
+ K.renderShadow(Math.max(0, sy.x) * 0.9);
772
+ K.draw();
773
+ }
774
+
775
+ // ── the address: a click lands on the nearest curb, and the card under the map names it and confirms it ──
776
+ const card = document.createElement('div'); card.className = 'pin-addr';
777
+ card.innerHTML = '<div class="pin-addr-t" role="status"><span class="pin-addr-k"></span><span class="pin-addr-a"></span><span class="pin-addr-b"></span></div>'
778
+ + '<button class="pin-addr-go" type="button" aria-disabled="true"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.6 8.4l2.9 2.9 5.9-6.6"/></svg><span></span></button>';
779
+ container.appendChild(card);
780
+ const cardT = card.querySelector('.pin-addr-t'), cardA = card.querySelector('.pin-addr-a'), cardB = card.querySelector('.pin-addr-b'), go = card.querySelector('.pin-addr-go'), goT = go.querySelector('span');
781
+ card.querySelector('.pin-addr-k').textContent = L.title; goT.textContent = L.confirm;
782
+ let here = null;
783
+ const emit = (type) => root.dispatchEvent(new CustomEvent(type, { bubbles: true, detail: here && { address: here.address, neighbourhood: here.hood, place: here.place || null } }));
784
+ let cardTimer = 0, confirmed = false, isReady = null;
785
+ function showAddress(sn, place) {
786
+ const a = sn.address, b = place ? `${place} · ${sn.hood}` : sn.hood;
787
+ if (cardA.textContent === a && cardB.textContent === b) return;
788
+ const put = () => { cardA.textContent = a; cardB.textContent = b; cardT.classList.remove('swap'); };
789
+ clearTimeout(cardTimer);
790
+ if (!cardA.textContent || reduce || o.manual) { put(); return; }
791
+ cardT.classList.add('swap'); cardTimer = setTimeout(put, 140); // out, new text, back in
792
+ }
793
+ function ready(r) { r = r && !confirmed; if (r !== isReady) { isReady = r; go.setAttribute('aria-disabled', r ? 'false' : 'true'); } }
794
+ function confirmIt(v) { confirmed = v; go.classList.toggle('done', v); goT.textContent = v ? L.confirmed : L.confirm; isReady = null; }
795
+ go.addEventListener('click', () => {
796
+ if (state !== 'stuck' || confirmed) return;
797
+ confirmIt(true); ready(false);
798
+ rip = 0; ripX = sx.x; ripZ = sz.x; ripK = 0.55; // a soft ripple from the pin
799
+ emit('pinconfirm');
800
+ });
801
+ function pick(x, z) {
802
+ x = THREE.MathUtils.clamp(x, -A[0], A[0]); z = THREE.MathUtils.clamp(z, A[1], A[2]);
803
+ const poi = city.pois.find((p) => Math.hypot(p.x - x, p.z - z) < 0.02), sn = poi ? poi.snap : city.snap(x, z);
804
+ confirmIt(false); showAddress(sn, poi && poi.name);
805
+ here = Object.assign({ place: poi && poi.name }, sn); emit('pinchange');
806
+ goTo(sn.x, sn.z);
807
+ }
808
+
809
+ // ── the names on the map: real type along the streets, as on a map, blurred by the same lens as the map under them ──
810
+ const layer = document.createElement('div'); layer.className = 'pin-mapl'; layer.setAttribute('aria-hidden', 'true'); container.insertBefore(layer, card);
811
+ const cands = [], labels = [], meas = document.createElement('canvas').getContext('2d');
812
+ for (const st of city.streets) {
813
+ const cls = st.kind === 'st' ? '' : 'av', pr = st.kind === 'av' ? 0 : st.kind === 'bank' ? 1 : 2;
814
+ const pieces = st.kind === 'bank' ? st.pieces.filter((_, k) => k % 7 === 4) : st.pieces.filter((p) => Math.hypot(p.b[0] - p.a[0], p.b[1] - p.a[1]) > 0.12);
815
+ for (const p of pieces) cands.push({ text: st.name, cls, pr, x: (p.a[0] + p.b[0]) / 2, z: (p.a[1] + p.b[1]) / 2, dir: [p.b[0] - p.a[0], p.b[1] - p.a[1]], max: st.kind === 'st' ? 2 : 3, key: st.id });
816
+ }
817
+ for (const p of city.pois) cands.push({ text: p.name, cls: 'poi', pr: 2, x: p.x, z: p.z, dx: 9, max: 1, key: 'o' + p.id, kind: 'poi' });
818
+ city.parks.forEach((p, k) => { const [a, b, c, d] = city.parkRect(p), [x, z] = city.toW((a + b) / 2, (c + d) / 2); cands.push({ text: p.name, cls: 'park', pr: 2, x, z, max: 1, key: 'p' + k }); });
819
+ for (let x = -1.4; x <= 1.41; x += 0.35) cands.push({ text: NAMES.river, cls: 'river', pr: 2, x, z: city.zr(x), dir: [1, -0.22 + 0.14 * Math.cos(1.4 * x + 0.6)], max: 1, key: 'w' });
820
+ cands.sort((a, b) => a.pr - b.pr || Math.hypot(a.x - PIN.home[0], a.z - PIN.home[1]) - Math.hypot(b.x - PIN.home[0], b.z - PIN.home[1]));
821
+ const _p = new THREE.Vector3();
822
+ const toScreen = (x, y, z) => { _p.set(x, y, z).project(K.camera); return [(_p.x + 1) / 2 * container.clientWidth, (1 - _p.y) / 2 * container.clientHeight]; };
823
+ const wpp = (x, y, z) => 2 * Math.hypot(x - K.camera.position.x, y - K.camera.position.y, z - K.camera.position.z) * Math.tan(THREE.MathUtils.degToRad(K.camera.fov / 2)) / container.clientHeight;
824
+ function layout() {
825
+ const W = container.clientWidth, H = container.clientHeight; if (!W || !H) return;
826
+ K.camera.updateMatrixWorld();
827
+ const top = card.offsetTop - 8, placed = [], count = {}; let places = 0;
828
+ layer.textContent = ''; labels.length = 0;
829
+ for (const c of cands) {
830
+ if ((count[c.key] || 0) >= c.max || (c.kind === 'poi' && places >= 6)) continue;
831
+ const [x, y] = toScreen(c.x, 0, c.z); let ang = 0;
832
+ if (c.dir) {
833
+ const L = Math.hypot(...c.dir), [x2, y2] = toScreen(c.x + c.dir[0] / L * 0.05, 0, c.z + c.dir[1] / L * 0.05);
834
+ ang = Math.atan2(y2 - y, x2 - x); if (ang > Math.PI / 2) ang -= Math.PI; if (ang < -Math.PI / 2) ang += Math.PI;
835
+ if (Math.abs(ang) > 1.36) continue; // too steep to read
836
+ }
837
+ meas.font = `${c.cls === 'river' ? 'italic ' : ''}500 ${c.cls === 'av' ? 11 : 10.5}px "DM Sans", sans-serif`;
838
+ const w = meas.measureText(c.text).width * 1.03 + 2, cx = x + (c.dx ? c.dx + w / 2 : 0), cy = y;
839
+ const n = Math.max(2, Math.ceil(w / 13)), circ = [];
840
+ for (let k = 0; k < n; k++) { const t = (k / (n - 1) - 0.5) * (w - 8); circ.push([cx + Math.cos(ang) * t, cy + Math.sin(ang) * t]); }
841
+ if (circ.some(([px, py]) => px < 10 || px > W - 10 || py < 10 || py > top)) continue;
842
+ if (circ.some(([px, py]) => placed.some(([qx, qy]) => (qx - px) ** 2 + (qy - py) ** 2 < 15 * 15))) continue;
843
+ if (count[c.key] && labels.some((l) => l.key === c.key && Math.hypot(l.cx - cx, l.cy - cy) < 170)) continue;
844
+ placed.push(...circ); count[c.key] = (count[c.key] || 0) + 1; if (c.kind === 'poi') places++;
845
+ const el = document.createElement('span'); el.className = 'pin-ml ' + c.cls; el.textContent = c.text;
846
+ el.style.transform = `translate(${cx.toFixed(1)}px, ${cy.toFixed(1)}px) translate(-50%, -50%) rotate(${ang.toFixed(4)}rad)`;
847
+ layer.appendChild(el);
848
+ labels.push({ el, key: c.key, cx, cy, circ, x: c.x, z: c.z, blur: -1, op: -1, vis: 1 });
849
+ if (labels.length >= 40) break;
850
+ }
851
+ labelStep(10);
852
+ }
853
+ const _f = new THREE.Vector3(), _r = new THREE.Vector3(), _w = new THREE.Vector3();
854
+ function lensBlur(x, z) { // the map shader's lensBlur, in JS
855
+ const B = MU.uBlur.value; _w.set(x - focus.x, 0, z - focus.y);
856
+ const e = Math.hypot(_w.dot(_f), _w.dot(_r) * B.z) - B.y;
857
+ return B.x * 0.5 * (e + Math.sqrt(e * e + 0.01));
858
+ }
859
+ function labelStep(dt) {
860
+ K.camera.getWorldDirection(_f); _r.crossVectors(_f, Y).normalize();
861
+ const [hx, hy] = toScreen(sx.x, sy.x + PIN.c, sz.x), [tx, ty] = toScreen(sx.x, sy.x, sz.x), rp = PIN.r / wpp(sx.x, sy.x + PIN.c, sz.x);
862
+ const segD = (px, py) => { const vx = tx - hx, vy = ty - hy, t = clamp01(((px - hx) * vx + (py - hy) * vy) / (vx * vx + vy * vy || 1)); return Math.hypot(px - hx - vx * t, py - hy - vy * t); };
863
+ for (const l of labels) {
864
+ const hide = state !== 'wait' && l.circ.some(([px, py]) => Math.hypot(px - hx, py - hy) < rp + 8 || segD(px, py) < rp * 0.5 + 6); // out of the pin's way
865
+ l.vis = damp(l.vis, hide ? 0 : 1, 10, dt);
866
+ const b = Math.max(0, lensBlur(l.x, l.z) / wpp(l.x, 0, l.z) - 0.35), op = clamp01(1.3 - b * 0.38) * l.vis;
867
+ if (Math.abs(b - l.blur) > 0.06) { l.el.style.filter = b < 0.1 ? '' : `blur(${b.toFixed(2)}px)`; l.blur = b; }
868
+ if (Math.abs(op - l.op) > 0.01) { l.el.style.opacity = op.toFixed(3); l.op = op; }
869
+ }
870
+ }
871
+ K.onResize = () => { behind(PIN.home[0], 0, PIN.home[1], _b0); layout(); };
872
+ let dead = false;
873
+ const refit = () => { if (!dead) layout(); };
874
+ if (document.fonts) { document.fonts.ready.then(refit); document.fonts.addEventListener('loadingdone', refit); }
875
+
876
+ // ── hover (mouse and pen): the address under the pointer, the street lit up, a small ring on the curb ──
877
+ const ray = new THREE.Raycaster(), floor = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0), hit = new THREE.Vector3(), ndc = new THREE.Vector2();
878
+ const tag = document.createElement('div'), faces = [document.createElement('span'), document.createElement('span')];
879
+ tag.className = 'pin-tag'; tag.setAttribute('aria-hidden', 'true'); tag.append(...faces); container.appendChild(tag);
880
+ const tg = { f: null, shown: 0, x: 0, y: 0, tx: 0, ty: 0, w: 0, front: 0, key: '', text: '' }, hv = { x: 0, z: 0, k: 0 };
881
+ const head = new THREE.Sphere(new THREE.Vector3(), PIN.r), tip = new THREE.Sphere(new THREE.Vector3(), 0.14);
882
+ const ribbons = [0, 1].map(() => {
883
+ const m = new THREE.Mesh(new THREE.BufferGeometry(), ribbonMaterial(MU)); m.position.y = 0.001; m.renderOrder = 1; m.visible = false; m.frustumCulled = false; scene.add(m);
884
+ return { m, id: null, k: 0 };
885
+ });
886
+ const ribbonGeo = new Map();
887
+ function featureAt(x, z, tol) {
888
+ const cx = THREE.MathUtils.clamp(x, -A[0], A[0]), cz = THREE.MathUtils.clamp(z, A[1], A[2]);
889
+ const poi = city.pois.find((p) => Math.hypot(p.x - x, p.z - z) < 0.016 + tol);
890
+ if (poi) return { key: 'o' + poi.id, label: poi.name, sub: poi.snap.address, ring: [poi.x, poi.z] };
891
+ const sn = city.snap(cx, cz);
892
+ if (!city.land(x, z, CITY.BR)) return { key: 'w', label: NAMES.river, sub: sn.address, ring: [sn.x, sn.z] };
893
+ const pk = city.parkOf(...city.cellOf(x, z));
894
+ if (pk >= 0) return { key: 'p' + pk, label: city.parks[pk].name, sub: sn.address, ring: [sn.x, sn.z] };
895
+ return { key: 's' + sn.street.id, street: sn.street, label: sn.address, sub: sn.hood, ring: [sn.x, sn.z] };
896
+ }
897
+ function face(el, f) { el.textContent = f.label; if (f.sub) { const i = document.createElement('i'); i.textContent = f.sub; el.append(i); } }
898
+ function hover(f) {
899
+ const was = tg.f; tg.f = f; if (!f) return;
900
+ if (!was) { hv.x = f.ring[0]; hv.z = f.ring[1]; }
901
+ if (f.street && !ribbons.some((r) => r.id === f.street.id)) {
902
+ const r = ribbons.reduce((a, b) => (a.k < b.k ? a : b)); r.id = f.street.id; r.k = 0;
903
+ if (!ribbonGeo.has(f.street.id)) ribbonGeo.set(f.street.id, ribbonGeometry(f.street));
904
+ r.m.geometry = ribbonGeo.get(f.street.id);
905
+ }
906
+ const text = f.label + '|' + (f.sub || '');
907
+ if (text === tg.text) return;
908
+ tg.text = text;
909
+ if (f.key === tg.key && tg.shown > 0.02) { // the same street, another number: the text just changes
910
+ face(faces[tg.front], f); tg.w = faces[tg.front].offsetWidth; tag.style.width = tg.w + 'px'; return;
911
+ }
912
+ tg.key = f.key;
913
+ const next = faces[1 - tg.front]; face(next, f); tg.w = next.offsetWidth;
914
+ if (tg.shown < 0.02) { tag.style.transition = 'none'; tag.style.width = tg.w + 'px'; void tag.offsetWidth; tag.style.transition = ''; }
915
+ else tag.style.width = tg.w + 'px';
916
+ faces[tg.front].style.opacity = '0'; next.style.opacity = '1'; tg.front = 1 - tg.front;
917
+ }
918
+ function placeTag(px, py) { // above and to the right of the pointer, flipped near the edges
919
+ const W = container.clientWidth;
920
+ tg.tx = px + 14 + tg.w > W - 10 ? px - 14 - tg.w : px + 14; tg.ty = py - 40 < 8 ? py + 18 : py - 40;
921
+ if (tg.shown < 0.02 || reduce) { tg.x = tg.tx; tg.y = tg.ty; }
922
+ }
923
+ function aimAt(clientX, clientY) { // the floor under a pointer, or null over the pin
924
+ const r = K.canvas.getBoundingClientRect();
925
+ ndc.set((clientX - r.left) / r.width * 2 - 1, -((clientY - r.top) / r.height) * 2 + 1);
926
+ ray.setFromCamera(ndc, K.camera);
927
+ head.center.set(sx.x, sy.x + PIN.c, sz.x); tip.center.set(sx.x, sy.x + 0.22, sz.x);
928
+ if (ray.ray.intersectsSphere(head) || ray.ray.intersectsSphere(tip) || !ray.ray.intersectPlane(floor, hit)) return null;
929
+ return hit;
930
+ }
931
+ function look(clientX, clientY) {
932
+ const h = aimAt(clientX, clientY); if (!h) { hover(null); return; }
933
+ const r = K.canvas.getBoundingClientRect();
934
+ hover(featureAt(h.x, h.z, 4 * wpp(h.x, 0, h.z)));
935
+ placeTag(clientX - r.left, clientY - r.top);
936
+ }
937
+ K.canvas.addEventListener('pointermove', (e) => { if (e.pointerType !== 'touch') look(e.clientX, e.clientY); });
938
+ K.canvas.addEventListener('pointerleave', () => hover(null));
939
+ K.canvas.addEventListener('pointerdown', (e) => { const h = aimAt(e.clientX, e.clientY); if (h) pick(h.x, h.z); });
940
+ function hoverStep(dt) {
941
+ const f = tg.f;
942
+ for (const r of ribbons) {
943
+ const on = !!(f && f.street && f.street.id === r.id);
944
+ r.k = damp(r.k, on ? 1 : 0, on ? 12 : 7, dt); r.m.visible = r.k > 0.003; r.m.material.uniforms.uK.value = r.k;
945
+ }
946
+ if (f) { hv.x = reduce ? f.ring[0] : damp(hv.x, f.ring[0], 30, dt); hv.z = reduce ? f.ring[1] : damp(hv.z, f.ring[1], 30, dt); }
947
+ hv.k = damp(hv.k, f ? 1 : 0, f ? 14 : 9, dt);
948
+ tg.shown = damp(tg.shown, f ? 1 : 0, f ? 16 : 10, dt);
949
+ tg.x = damp(tg.x, tg.tx, 24, dt); tg.y = damp(tg.y, tg.ty, 24, dt);
950
+ tag.style.opacity = tg.shown < 0.003 ? '0' : tg.shown.toFixed(3);
951
+ tag.style.transform = `translate3d(${tg.x.toFixed(1)}px, ${(tg.y + (1 - tg.shown) * 4).toFixed(1)}px, 0)`;
952
+ }
953
+
954
+ function replay() { if (reduce || state === 'wait') { pick(...PIN.home); return; } leave(); }
955
+ K.onVisible = () => { if (state === 'wait' && o.autoplay !== false) pick(...PIN.home); };
956
+
957
+ reset(); focusAt(...PIN.home);
958
+ K.start((dt, now) => {
959
+ hoverStep(dt); labelStep(dt);
960
+ step(dt, now);
961
+ render(now);
962
+ });
963
+ return {
964
+ ready: Promise.resolve(),
965
+ replay,
966
+ confirm: () => go.click(),
967
+ tick: (dt, now) => K.tick && K.tick(dt, now),
968
+ project: (x, z) => { K.camera.updateMatrixWorld(); return toScreen(x, 0, z).slice(0, 2); }, // where a point of the map is, in the root's pixels
969
+ home: () => city.snap(...PIN.home),
970
+ get address() { return here && { address: here.address, neighbourhood: here.hood, place: here.place || null }; },
971
+ destroy() {
972
+ dead = true; if (document.fonts) document.fonts.removeEventListener('loadingdone', refit);
973
+ clearTimeout(cardTimer); K.destroy();
974
+ card.remove(); tag.remove(); layer.remove(); root.classList.remove('pin-root');
975
+ }
976
+ };
977
+ }