craft-library 0.5.0 → 0.6.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/README.md +27 -3
- package/dist/cdn.js +3 -3
- package/dist/craft.js +63 -2
- package/dist/elements.js +18 -2
- package/dist/index.js +17 -1
- package/dist/panorama-assets.js +1 -1
- package/dist/pin-load.js +2 -0
- package/dist/pin-mount.js +30 -0
- package/dist/pin.cdn.js +977 -0
- package/dist/pin.js +977 -0
- package/package.json +6 -3
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
|
+
}
|