@johnmorrisdotca/chikyu-earth 0.4.0 → 0.5.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/CHANGELOG.md +36 -0
- package/NOTICE.md +28 -1
- package/README.md +30 -0
- package/dist/element.d.ts +6 -1
- package/dist/element.js +55 -3
- package/dist/markers/cluster.d.ts +11 -0
- package/dist/markers/cluster.js +26 -0
- package/dist/markers/index.d.ts +5 -0
- package/dist/markers/index.js +6 -0
- package/dist/markers/labels.d.ts +24 -0
- package/dist/markers/labels.js +52 -0
- package/dist/markers/layer.d.ts +49 -0
- package/dist/markers/layer.js +434 -0
- package/dist/markers/project.d.ts +21 -0
- package/dist/markers/project.js +43 -0
- package/dist/markers/types.d.ts +38 -0
- package/dist/markers/types.js +1 -0
- package/dist/realistic/earth.d.ts +56 -0
- package/dist/realistic/earth.js +256 -0
- package/dist/realistic/element.d.ts +53 -0
- package/dist/realistic/element.js +280 -0
- package/dist/realistic/gate.d.ts +20 -0
- package/dist/realistic/gate.js +40 -0
- package/dist/realistic/index.d.ts +5 -0
- package/dist/realistic/index.js +6 -0
- package/dist/realistic/shaders.d.ts +5 -0
- package/dist/realistic/shaders.js +79 -0
- package/dist/realistic/sun.d.ts +7 -0
- package/dist/realistic/sun.js +28 -0
- package/dist/realistic/tiers.d.ts +20 -0
- package/dist/realistic/tiers.js +30 -0
- package/dist/textures/clouds-1024.webp +0 -0
- package/dist/textures/clouds-2048.webp +0 -0
- package/dist/textures/day-1024.webp +0 -0
- package/dist/textures/day-2048.webp +0 -0
- package/dist/textures/day-4096.webp +0 -0
- package/dist/textures/night-1024.webp +0 -0
- package/dist/textures/night-2048.webp +0 -0
- package/dist/textures/water-1024.webp +0 -0
- package/dist/textures/water-2048.webp +0 -0
- package/dist/view.d.ts +2 -0
- package/dist/view.js +5 -0
- package/package.json +45 -7
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
// Markers over the globe, for either look: dots drawn in one pass on a 2D canvas, a few labels as real buttons, crowded places gathered into clusters.
|
|
2
|
+
// It only draws when the view or the markers change (the element tells it), reads no layout to place a label, and shows no more labels than `maxLabels`.
|
|
3
|
+
import { clusterPoints } from "./cluster.js";
|
|
4
|
+
import { placeLabels } from "./labels.js";
|
|
5
|
+
import { canvasRatio, projectAll } from "./project.js";
|
|
6
|
+
const CSS = `
|
|
7
|
+
.cm-canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
|
|
8
|
+
.cm-labels{position:absolute;inset:0;overflow:hidden;pointer-events:none}
|
|
9
|
+
.cm-label{position:absolute;left:0;top:0;margin:0;padding:0 6px;height:20px;line-height:20px;font:12px/20px system-ui,-apple-system,"Segoe UI",sans-serif;color:#fff;background:rgba(7,11,18,.72);border:0;border-radius:10px;white-space:nowrap;pointer-events:none;will-change:transform}
|
|
10
|
+
.cm-label:focus-visible{outline:2px solid #6cd0ff;outline-offset:1px}
|
|
11
|
+
.cm-label[hidden]{display:none}
|
|
12
|
+
.cm-list{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;margin:0;padding:0}
|
|
13
|
+
`;
|
|
14
|
+
const EDGE = 16;
|
|
15
|
+
const LABEL_FONT = "12px system-ui, -apple-system, sans-serif";
|
|
16
|
+
const TEXT_LIST_MAX = 200;
|
|
17
|
+
export function attachMarkers(element, options = {}) {
|
|
18
|
+
const host = element.overlay;
|
|
19
|
+
const root = host.overlayRoot;
|
|
20
|
+
const doc = root.ownerDocument;
|
|
21
|
+
const maxLabels = options.maxLabels ?? 24;
|
|
22
|
+
const clusterRadius = options.clusterRadius ?? 28;
|
|
23
|
+
const defaultColor = options.color ?? "#6cd0ff";
|
|
24
|
+
const shadow = root.getRootNode();
|
|
25
|
+
const sheet = typeof CSSStyleSheet !== "undefined" && "adoptedStyleSheets" in doc && shadow instanceof ShadowRoot ? new CSSStyleSheet() : null;
|
|
26
|
+
let style = null;
|
|
27
|
+
if (sheet && shadow instanceof ShadowRoot) {
|
|
28
|
+
sheet.replaceSync(CSS);
|
|
29
|
+
shadow.adoptedStyleSheets = [...shadow.adoptedStyleSheets, sheet];
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
style = doc.createElement("style");
|
|
33
|
+
style.textContent = CSS;
|
|
34
|
+
root.append(style);
|
|
35
|
+
}
|
|
36
|
+
const canvas = doc.createElement("canvas");
|
|
37
|
+
canvas.className = "cm-canvas";
|
|
38
|
+
canvas.setAttribute("aria-hidden", "true");
|
|
39
|
+
const labelBox = doc.createElement("div");
|
|
40
|
+
labelBox.className = "cm-labels";
|
|
41
|
+
const list = doc.createElement("ul");
|
|
42
|
+
list.className = "cm-list";
|
|
43
|
+
list.setAttribute("aria-label", "Places marked on the globe");
|
|
44
|
+
root.append(canvas, labelBox, list);
|
|
45
|
+
const ctx = canvas.getContext("2d");
|
|
46
|
+
const store = new Map();
|
|
47
|
+
const hidden = new Set();
|
|
48
|
+
let compiled = null;
|
|
49
|
+
let listDirty = true;
|
|
50
|
+
let renders = 0;
|
|
51
|
+
let last = host.pose;
|
|
52
|
+
let xs = new Float32Array(0);
|
|
53
|
+
let ys = new Float32Array(0);
|
|
54
|
+
let zs = new Float32Array(0);
|
|
55
|
+
const stats = { markers: 0, clusters: 0, labels: 0, renders: 0 };
|
|
56
|
+
// what the last render drew, for picking
|
|
57
|
+
let hitX = [];
|
|
58
|
+
let hitY = [];
|
|
59
|
+
let hitR = [];
|
|
60
|
+
let hitMembers = [];
|
|
61
|
+
let hitHover = null;
|
|
62
|
+
const pool = [];
|
|
63
|
+
let shown = 0;
|
|
64
|
+
const widths = new Map();
|
|
65
|
+
const compile = () => {
|
|
66
|
+
if (compiled)
|
|
67
|
+
return compiled;
|
|
68
|
+
const items = [...store.values()].filter((m) => !m.group || !hidden.has(m.group));
|
|
69
|
+
const lons = new Float64Array(items.length);
|
|
70
|
+
const lats = new Float64Array(items.length);
|
|
71
|
+
const sizes = new Float32Array(items.length);
|
|
72
|
+
items.forEach((m, i) => {
|
|
73
|
+
lons[i] = m.lon;
|
|
74
|
+
lats[i] = m.lat;
|
|
75
|
+
sizes[i] = Math.min(32, Math.max(4, m.size ?? 8));
|
|
76
|
+
});
|
|
77
|
+
compiled = { items, lons, lats, sizes };
|
|
78
|
+
if (xs.length < items.length) {
|
|
79
|
+
xs = new Float32Array(items.length);
|
|
80
|
+
ys = new Float32Array(items.length);
|
|
81
|
+
zs = new Float32Array(items.length);
|
|
82
|
+
}
|
|
83
|
+
return compiled;
|
|
84
|
+
};
|
|
85
|
+
const textWidth = (text) => {
|
|
86
|
+
let w = widths.get(text);
|
|
87
|
+
if (w === undefined) {
|
|
88
|
+
if (ctx) {
|
|
89
|
+
ctx.font = LABEL_FONT;
|
|
90
|
+
w = Math.ceil(ctx.measureText(text).width);
|
|
91
|
+
}
|
|
92
|
+
else
|
|
93
|
+
w = text.length * 7;
|
|
94
|
+
if (widths.size > 2000)
|
|
95
|
+
widths.clear();
|
|
96
|
+
widths.set(text, w);
|
|
97
|
+
}
|
|
98
|
+
return w;
|
|
99
|
+
};
|
|
100
|
+
const rebuildList = () => {
|
|
101
|
+
listDirty = false;
|
|
102
|
+
list.replaceChildren();
|
|
103
|
+
const entries = [...store.values()].filter((m) => !m.group || !hidden.has(m.group));
|
|
104
|
+
const summary = doc.createElement("li");
|
|
105
|
+
summary.textContent = `${entries.length} place${entries.length === 1 ? "" : "s"} marked on the globe`;
|
|
106
|
+
list.append(summary);
|
|
107
|
+
if (entries.length > TEXT_LIST_MAX)
|
|
108
|
+
return;
|
|
109
|
+
for (const m of entries) {
|
|
110
|
+
const li = doc.createElement("li");
|
|
111
|
+
li.textContent = m.description ?? m.label ?? `${m.lat.toFixed(1)}, ${m.lon.toFixed(1)}`;
|
|
112
|
+
list.append(li);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const drawIcon = (c, icon, x, y, r, color) => {
|
|
116
|
+
c.fillStyle = color;
|
|
117
|
+
c.strokeStyle = "rgba(0,0,0,.6)";
|
|
118
|
+
c.lineWidth = 1;
|
|
119
|
+
c.beginPath();
|
|
120
|
+
if (icon === "square")
|
|
121
|
+
c.rect(x - r, y - r, 2 * r, 2 * r);
|
|
122
|
+
else if (icon === "diamond") {
|
|
123
|
+
c.moveTo(x, y - r * 1.3);
|
|
124
|
+
c.lineTo(x + r * 1.3, y);
|
|
125
|
+
c.lineTo(x, y + r * 1.3);
|
|
126
|
+
c.lineTo(x - r * 1.3, y);
|
|
127
|
+
c.closePath();
|
|
128
|
+
}
|
|
129
|
+
else
|
|
130
|
+
c.arc(x, y, r, 0, Math.PI * 2);
|
|
131
|
+
if (icon === "ring") {
|
|
132
|
+
c.lineWidth = Math.max(1.5, r * 0.35);
|
|
133
|
+
c.strokeStyle = color;
|
|
134
|
+
c.stroke();
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
c.fill();
|
|
138
|
+
c.stroke();
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const render = (pose = last) => {
|
|
142
|
+
if (!pose || !ctx)
|
|
143
|
+
return;
|
|
144
|
+
last = pose;
|
|
145
|
+
renders += 1;
|
|
146
|
+
stats.renders = renders;
|
|
147
|
+
if (listDirty)
|
|
148
|
+
rebuildList();
|
|
149
|
+
root.style.transform = pose.transform;
|
|
150
|
+
const data = compile();
|
|
151
|
+
const ratio = canvasRatio(pose.width, pose.height, globalThis.devicePixelRatio || 1);
|
|
152
|
+
const pw = Math.max(1, Math.round(pose.width * ratio));
|
|
153
|
+
const ph = Math.max(1, Math.round(pose.height * ratio));
|
|
154
|
+
if (canvas.width !== pw || canvas.height !== ph) {
|
|
155
|
+
canvas.width = pw;
|
|
156
|
+
canvas.height = ph;
|
|
157
|
+
}
|
|
158
|
+
ctx.setTransform(pw / pose.width, 0, 0, ph / pose.height, 0, 0);
|
|
159
|
+
ctx.clearRect(0, 0, pose.width, pose.height);
|
|
160
|
+
const n = data.items.length;
|
|
161
|
+
projectAll(data.lons, data.lats, n, pose, xs, ys, zs);
|
|
162
|
+
const visible = [];
|
|
163
|
+
for (let i = 0; i < n; i += 1) {
|
|
164
|
+
if (zs[i] > 0.02 && xs[i] > -EDGE && xs[i] < pose.width + EDGE && ys[i] > -EDGE && ys[i] < pose.height + EDGE)
|
|
165
|
+
visible.push(i);
|
|
166
|
+
}
|
|
167
|
+
const groups = clusterRadius > 0 ? clusterPoints(visible, visible.length, xs, ys, clusterRadius) : visible.map((i) => ({ x: xs[i], y: ys[i], members: [i] }));
|
|
168
|
+
hitX = [];
|
|
169
|
+
hitY = [];
|
|
170
|
+
hitR = [];
|
|
171
|
+
hitMembers = [];
|
|
172
|
+
const items = [];
|
|
173
|
+
const labelled = [];
|
|
174
|
+
let clusters = 0;
|
|
175
|
+
for (const g of groups) {
|
|
176
|
+
if (g.members.length === 1) {
|
|
177
|
+
const i = g.members[0];
|
|
178
|
+
const m = data.items[i];
|
|
179
|
+
const r = data.sizes[i] / 2;
|
|
180
|
+
const edge = zs[i] < 0.15 ? 0.35 : zs[i] < 0.3 ? 0.7 : 1;
|
|
181
|
+
ctx.globalAlpha = edge;
|
|
182
|
+
drawIcon(ctx, m.icon ?? "dot", g.x, g.y, r, m.color ?? defaultColor);
|
|
183
|
+
ctx.globalAlpha = 1;
|
|
184
|
+
hitX.push(g.x);
|
|
185
|
+
hitY.push(g.y);
|
|
186
|
+
hitR.push(Math.max(10, r + 4));
|
|
187
|
+
hitMembers.push([i]);
|
|
188
|
+
if (m.label) {
|
|
189
|
+
items.push({ x: g.x, y: g.y, r, w: textWidth(m.label) + 12, h: 20, priority: m.priority ?? 0 });
|
|
190
|
+
labelled.push(i);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
clusters += 1;
|
|
195
|
+
const r = 10 + Math.min(10, Math.log2(g.members.length) * 3);
|
|
196
|
+
ctx.fillStyle = "rgba(108,208,255,.85)";
|
|
197
|
+
ctx.strokeStyle = "rgba(0,0,0,.6)";
|
|
198
|
+
ctx.lineWidth = 1;
|
|
199
|
+
ctx.beginPath();
|
|
200
|
+
ctx.arc(g.x, g.y, r, 0, Math.PI * 2);
|
|
201
|
+
ctx.fill();
|
|
202
|
+
ctx.stroke();
|
|
203
|
+
ctx.fillStyle = "#06101c";
|
|
204
|
+
ctx.font = "bold 11px system-ui, sans-serif";
|
|
205
|
+
ctx.textAlign = "center";
|
|
206
|
+
ctx.textBaseline = "middle";
|
|
207
|
+
ctx.fillText(String(g.members.length), g.x, g.y + 0.5);
|
|
208
|
+
hitX.push(g.x);
|
|
209
|
+
hitY.push(g.y);
|
|
210
|
+
hitR.push(r + 2);
|
|
211
|
+
hitMembers.push(g.members);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const placed = placeLabels(items, pose.width, pose.height, maxLabels);
|
|
215
|
+
for (let k = 0; k < placed.length; k += 1) {
|
|
216
|
+
const p = placed[k];
|
|
217
|
+
const m = data.items[labelled[p.item]];
|
|
218
|
+
let button = pool[k];
|
|
219
|
+
if (!button) {
|
|
220
|
+
button = doc.createElement("button");
|
|
221
|
+
button.type = "button";
|
|
222
|
+
button.className = "cm-label";
|
|
223
|
+
pool[k] = button;
|
|
224
|
+
labelBox.append(button);
|
|
225
|
+
}
|
|
226
|
+
if (button.dataset["id"] !== m.id) {
|
|
227
|
+
button.dataset["id"] = m.id;
|
|
228
|
+
button.textContent = m.label ?? "";
|
|
229
|
+
button.setAttribute("aria-label", m.description ?? m.label ?? m.id);
|
|
230
|
+
}
|
|
231
|
+
if (k >= shown)
|
|
232
|
+
button.hidden = false;
|
|
233
|
+
button.style.transform = `translate(${Math.round(p.left)}px,${Math.round(p.top)}px)`;
|
|
234
|
+
}
|
|
235
|
+
for (let k = placed.length; k < shown; k += 1) {
|
|
236
|
+
const button = pool[k];
|
|
237
|
+
if (button && !button.hidden) {
|
|
238
|
+
button.hidden = true;
|
|
239
|
+
delete button.dataset["id"];
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
shown = placed.length;
|
|
243
|
+
stats.markers = visible.length - groups.reduce((sum, g) => sum + (g.members.length > 1 ? g.members.length : 0), 0);
|
|
244
|
+
stats.clusters = clusters;
|
|
245
|
+
stats.labels = placed.length;
|
|
246
|
+
};
|
|
247
|
+
let scheduled = false;
|
|
248
|
+
const changed = () => {
|
|
249
|
+
compiled = null;
|
|
250
|
+
listDirty = true;
|
|
251
|
+
if (scheduled)
|
|
252
|
+
return;
|
|
253
|
+
scheduled = true;
|
|
254
|
+
queueMicrotask(() => {
|
|
255
|
+
scheduled = false;
|
|
256
|
+
render();
|
|
257
|
+
});
|
|
258
|
+
};
|
|
259
|
+
const unsubscribe = host.subscribe((pose) => render(pose));
|
|
260
|
+
// --- events ------------------------------------------------------------------------------------------------------------------------------------
|
|
261
|
+
const detail = (marker, x, y, cluster) => ({ marker, x, y, ...(cluster ? { cluster } : {}) });
|
|
262
|
+
const emit = (type, d) => {
|
|
263
|
+
element.dispatchEvent(new CustomEvent(type, { detail: d, bubbles: true, composed: true }));
|
|
264
|
+
};
|
|
265
|
+
const pick = (x, y) => {
|
|
266
|
+
let best = -1;
|
|
267
|
+
let bestD = Infinity;
|
|
268
|
+
for (let k = 0; k < hitX.length; k += 1) {
|
|
269
|
+
const dx = x - hitX[k];
|
|
270
|
+
const dy = y - hitY[k];
|
|
271
|
+
const d = dx * dx + dy * dy;
|
|
272
|
+
if (d <= hitR[k] * hitR[k] && d < bestD) {
|
|
273
|
+
best = k;
|
|
274
|
+
bestD = d;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return best;
|
|
278
|
+
};
|
|
279
|
+
const local = (event) => {
|
|
280
|
+
const rect = element.getBoundingClientRect();
|
|
281
|
+
const scale = last && rect.width > 0 ? last.width / rect.width : 1;
|
|
282
|
+
return { x: (event.clientX - rect.left) * scale, y: (event.clientY - rect.top) * scale };
|
|
283
|
+
};
|
|
284
|
+
let moveAt = 0;
|
|
285
|
+
const onMove = (event) => {
|
|
286
|
+
const now = performance.now();
|
|
287
|
+
if (now - moveAt < 33)
|
|
288
|
+
return;
|
|
289
|
+
moveAt = now;
|
|
290
|
+
const { x, y } = local(event);
|
|
291
|
+
const k = pick(x, y);
|
|
292
|
+
const id = k >= 0 && hitMembers[k].length === 1 ? (compile().items[hitMembers[k][0]]?.id ?? null) : null;
|
|
293
|
+
const key = k >= 0 ? (id ?? `cluster:${k}`) : null;
|
|
294
|
+
if (key === hitHover)
|
|
295
|
+
return;
|
|
296
|
+
hitHover = key;
|
|
297
|
+
element.toggleAttribute("data-marker-hover", key !== null);
|
|
298
|
+
emit("chikyu-marker-hover", detail(id ? (store.get(id) ?? null) : null, x, y, k >= 0 && !id ? hitMembers[k].map((i) => compile().items[i].id) : undefined));
|
|
299
|
+
};
|
|
300
|
+
const onLeave = () => {
|
|
301
|
+
if (hitHover === null)
|
|
302
|
+
return;
|
|
303
|
+
hitHover = null;
|
|
304
|
+
element.removeAttribute("data-marker-hover");
|
|
305
|
+
emit("chikyu-marker-hover", detail(null, -1, -1));
|
|
306
|
+
};
|
|
307
|
+
let downX = 0;
|
|
308
|
+
let downY = 0;
|
|
309
|
+
// The shared controls capture the pointer, so a click is told from where the press began and where the pointer is let go, seen on the window.
|
|
310
|
+
const onDown = (event) => {
|
|
311
|
+
if (event.button !== 0)
|
|
312
|
+
return;
|
|
313
|
+
downX = event.clientX;
|
|
314
|
+
downY = event.clientY;
|
|
315
|
+
window.addEventListener("pointerup", onUp, { capture: true, once: true });
|
|
316
|
+
};
|
|
317
|
+
const onUp = (event) => {
|
|
318
|
+
if (Math.hypot(event.clientX - downX, event.clientY - downY) > 6)
|
|
319
|
+
return;
|
|
320
|
+
const { x, y } = local(event);
|
|
321
|
+
const k = pick(x, y);
|
|
322
|
+
if (k < 0)
|
|
323
|
+
return;
|
|
324
|
+
const members = hitMembers[k];
|
|
325
|
+
const items = compile().items;
|
|
326
|
+
if (members.length === 1)
|
|
327
|
+
emit("chikyu-marker-click", detail(items[members[0]] ?? null, x, y));
|
|
328
|
+
else
|
|
329
|
+
emit("chikyu-marker-click", detail(null, x, y, members.map((i) => items[i].id)));
|
|
330
|
+
};
|
|
331
|
+
const onLabelClick = (event) => {
|
|
332
|
+
const id = event.target?.dataset?.["id"];
|
|
333
|
+
const m = id ? store.get(id) : undefined;
|
|
334
|
+
if (!m || !last)
|
|
335
|
+
return;
|
|
336
|
+
const p = projectAt(m);
|
|
337
|
+
emit("chikyu-marker-click", detail(m, p.x, p.y));
|
|
338
|
+
};
|
|
339
|
+
const onLabelFocus = (event) => {
|
|
340
|
+
const id = event.target?.dataset?.["id"];
|
|
341
|
+
const m = id ? store.get(id) : undefined;
|
|
342
|
+
if (!m || !last)
|
|
343
|
+
return;
|
|
344
|
+
const p = projectAt(m);
|
|
345
|
+
emit("chikyu-marker-focus", detail(m, p.x, p.y));
|
|
346
|
+
};
|
|
347
|
+
const projectAt = (m) => {
|
|
348
|
+
const k = compile().items.indexOf(m);
|
|
349
|
+
return k >= 0 ? { x: xs[k], y: ys[k] } : { x: 0, y: 0 };
|
|
350
|
+
};
|
|
351
|
+
element.addEventListener("pointermove", onMove);
|
|
352
|
+
element.addEventListener("pointerleave", onLeave);
|
|
353
|
+
element.addEventListener("pointerdown", onDown);
|
|
354
|
+
labelBox.addEventListener("click", onLabelClick);
|
|
355
|
+
labelBox.addEventListener("focusin", onLabelFocus);
|
|
356
|
+
const api = {
|
|
357
|
+
add(marker) {
|
|
358
|
+
store.set(marker.id, { ...marker });
|
|
359
|
+
changed();
|
|
360
|
+
},
|
|
361
|
+
addAll(markers) {
|
|
362
|
+
for (const marker of markers)
|
|
363
|
+
store.set(marker.id, { ...marker });
|
|
364
|
+
changed();
|
|
365
|
+
},
|
|
366
|
+
update(id, patch) {
|
|
367
|
+
const found = store.get(id);
|
|
368
|
+
if (!found)
|
|
369
|
+
return;
|
|
370
|
+
store.set(id, { ...found, ...patch, id });
|
|
371
|
+
changed();
|
|
372
|
+
},
|
|
373
|
+
remove(id) {
|
|
374
|
+
if (store.delete(id))
|
|
375
|
+
changed();
|
|
376
|
+
},
|
|
377
|
+
clear() {
|
|
378
|
+
store.clear();
|
|
379
|
+
changed();
|
|
380
|
+
},
|
|
381
|
+
get: (id) => store.get(id),
|
|
382
|
+
list: () => [...store.values()],
|
|
383
|
+
get size() {
|
|
384
|
+
return store.size;
|
|
385
|
+
},
|
|
386
|
+
groups() {
|
|
387
|
+
const counts = new Map();
|
|
388
|
+
for (const m of store.values())
|
|
389
|
+
if (m.group)
|
|
390
|
+
counts.set(m.group, (counts.get(m.group) ?? 0) + 1);
|
|
391
|
+
for (const name of hidden)
|
|
392
|
+
if (!counts.has(name))
|
|
393
|
+
counts.set(name, 0);
|
|
394
|
+
return [...counts].map(([name, count]) => ({ name, count, visible: !hidden.has(name) }));
|
|
395
|
+
},
|
|
396
|
+
showGroup(name) {
|
|
397
|
+
if (hidden.delete(name))
|
|
398
|
+
changed();
|
|
399
|
+
},
|
|
400
|
+
hideGroup(name) {
|
|
401
|
+
if (!hidden.has(name)) {
|
|
402
|
+
hidden.add(name);
|
|
403
|
+
changed();
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
toggleGroup(name, force) {
|
|
407
|
+
const show = force ?? hidden.has(name);
|
|
408
|
+
if (show)
|
|
409
|
+
api.showGroup(name);
|
|
410
|
+
else
|
|
411
|
+
api.hideGroup(name);
|
|
412
|
+
return show;
|
|
413
|
+
},
|
|
414
|
+
stats,
|
|
415
|
+
detach() {
|
|
416
|
+
unsubscribe();
|
|
417
|
+
element.removeEventListener("pointermove", onMove);
|
|
418
|
+
element.removeEventListener("pointerleave", onLeave);
|
|
419
|
+
element.removeEventListener("pointerdown", onDown);
|
|
420
|
+
window.removeEventListener("pointerup", onUp, true);
|
|
421
|
+
labelBox.removeEventListener("click", onLabelClick);
|
|
422
|
+
labelBox.removeEventListener("focusin", onLabelFocus);
|
|
423
|
+
canvas.remove();
|
|
424
|
+
labelBox.remove();
|
|
425
|
+
list.remove();
|
|
426
|
+
style?.remove();
|
|
427
|
+
if (sheet && shadow instanceof ShadowRoot)
|
|
428
|
+
shadow.adoptedStyleSheets = shadow.adoptedStyleSheets.filter((s) => s !== sheet);
|
|
429
|
+
root.style.transform = "";
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
render();
|
|
433
|
+
return api;
|
|
434
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** The view: the point of the Earth at the middle of the disc, the disc's radius and its centre, in CSS pixels. */
|
|
2
|
+
export interface Pose {
|
|
3
|
+
lon: number;
|
|
4
|
+
lat: number;
|
|
5
|
+
radius: number;
|
|
6
|
+
cx: number;
|
|
7
|
+
cy: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Project a longitude and latitude (degrees) to the screen. `z` is the cosine of the angle from the middle of the disc: above 0 the point faces
|
|
11
|
+
* the viewer, below it is on the far side. North is up.
|
|
12
|
+
*/
|
|
13
|
+
export declare function project(lon: number, lat: number, pose: Pose): {
|
|
14
|
+
x: number;
|
|
15
|
+
y: number;
|
|
16
|
+
z: number;
|
|
17
|
+
};
|
|
18
|
+
/** Project many points into flat arrays (reused between calls); `z` is left as it is for a point's facing test. */
|
|
19
|
+
export declare function projectAll(lons: ArrayLike<number>, lats: ArrayLike<number>, count: number, pose: Pose, xs: Float32Array, ys: Float32Array, zs: Float32Array): void;
|
|
20
|
+
/** The pixel ratio for an overlay canvas: the device's, to 2, held to 4096 a side and 6 million pixels (the same budget as the 3D canvas). */
|
|
21
|
+
export declare function canvasRatio(width: number, height: number, deviceRatio: number, maxSide?: number, maxPixels?: number): number;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Where a point on the Earth lands on a screen, for the orthographic view both looks share. Pure.
|
|
2
|
+
const RAD = Math.PI / 180;
|
|
3
|
+
/**
|
|
4
|
+
* Project a longitude and latitude (degrees) to the screen. `z` is the cosine of the angle from the middle of the disc: above 0 the point faces
|
|
5
|
+
* the viewer, below it is on the far side. North is up.
|
|
6
|
+
*/
|
|
7
|
+
export function project(lon, lat, pose) {
|
|
8
|
+
const phi = lat * RAD;
|
|
9
|
+
const phi0 = pose.lat * RAD;
|
|
10
|
+
const delta = (lon - pose.lon) * RAD;
|
|
11
|
+
const cosPhi = Math.cos(phi);
|
|
12
|
+
const z = Math.sin(phi0) * Math.sin(phi) + Math.cos(phi0) * cosPhi * Math.cos(delta);
|
|
13
|
+
return {
|
|
14
|
+
x: pose.cx + pose.radius * cosPhi * Math.sin(delta),
|
|
15
|
+
y: pose.cy - pose.radius * (Math.cos(phi0) * Math.sin(phi) - Math.sin(phi0) * cosPhi * Math.cos(delta)),
|
|
16
|
+
z,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** Project many points into flat arrays (reused between calls); `z` is left as it is for a point's facing test. */
|
|
20
|
+
export function projectAll(lons, lats, count, pose, xs, ys, zs) {
|
|
21
|
+
const phi0 = pose.lat * RAD;
|
|
22
|
+
const sin0 = Math.sin(phi0);
|
|
23
|
+
const cos0 = Math.cos(phi0);
|
|
24
|
+
for (let i = 0; i < count; i += 1) {
|
|
25
|
+
const phi = lats[i] * RAD;
|
|
26
|
+
const delta = (lons[i] - pose.lon) * RAD;
|
|
27
|
+
const sinPhi = Math.sin(phi);
|
|
28
|
+
const cosPhi = Math.cos(phi);
|
|
29
|
+
const cosDelta = Math.cos(delta);
|
|
30
|
+
zs[i] = sin0 * sinPhi + cos0 * cosPhi * cosDelta;
|
|
31
|
+
xs[i] = pose.cx + pose.radius * cosPhi * Math.sin(delta);
|
|
32
|
+
ys[i] = pose.cy - pose.radius * (cos0 * sinPhi - sin0 * cosPhi * cosDelta);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** The pixel ratio for an overlay canvas: the device's, to 2, held to 4096 a side and 6 million pixels (the same budget as the 3D canvas). */
|
|
36
|
+
export function canvasRatio(width, height, deviceRatio, maxSide = 4096, maxPixels = 6_000_000) {
|
|
37
|
+
let ratio = Math.min(Math.max(deviceRatio, 1), 2);
|
|
38
|
+
ratio = Math.min(ratio, maxSide / Math.max(width, 1), maxSide / Math.max(height, 1));
|
|
39
|
+
const pixels = width * height * ratio * ratio;
|
|
40
|
+
if (pixels > maxPixels)
|
|
41
|
+
ratio *= Math.sqrt(maxPixels / pixels);
|
|
42
|
+
return Math.max(0.1, ratio);
|
|
43
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** The shapes a marker can be drawn as. */
|
|
2
|
+
export type MarkerIcon = "dot" | "ring" | "square" | "diamond";
|
|
3
|
+
/** A place on the Earth. Chikyu ships none: the page brings its own. */
|
|
4
|
+
export interface MarkerInput {
|
|
5
|
+
/** Your own unique name for it. */
|
|
6
|
+
id: string;
|
|
7
|
+
/** Degrees east and north. */
|
|
8
|
+
lon: number;
|
|
9
|
+
lat: number;
|
|
10
|
+
/** Shown beside the dot when there is room; also its name for a screen reader. */
|
|
11
|
+
label?: string;
|
|
12
|
+
/** What a screen reader says instead of the label (a sentence). */
|
|
13
|
+
description?: string;
|
|
14
|
+
/** Any CSS colour. Default the accent. */
|
|
15
|
+
color?: string;
|
|
16
|
+
icon?: MarkerIcon;
|
|
17
|
+
/** Diameter in CSS pixels, 4 to 32. Default 8. */
|
|
18
|
+
size?: number;
|
|
19
|
+
/** A name for a set of markers (cities, stadiums) that can be shown or hidden together. */
|
|
20
|
+
group?: string;
|
|
21
|
+
/** Higher keeps its label when labels crowd. Default 0. */
|
|
22
|
+
priority?: number;
|
|
23
|
+
/** Yours: given back in every event. */
|
|
24
|
+
data?: unknown;
|
|
25
|
+
}
|
|
26
|
+
/** What `chikyu-marker-*` events carry. A cluster click has `marker: null` and `cluster` listing the ids in it. */
|
|
27
|
+
export interface MarkerEventDetail {
|
|
28
|
+
marker: MarkerInput | null;
|
|
29
|
+
cluster?: string[];
|
|
30
|
+
/** Where the pointer or the marker is, in CSS pixels inside the globe's box. */
|
|
31
|
+
x: number;
|
|
32
|
+
y: number;
|
|
33
|
+
}
|
|
34
|
+
export interface GroupInfo {
|
|
35
|
+
name: string;
|
|
36
|
+
count: number;
|
|
37
|
+
visible: boolean;
|
|
38
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type * as THREE from "three";
|
|
2
|
+
import type { AssetCache, Item3D } from "@johnmorrisdotca/kazoku-ui/renderer";
|
|
3
|
+
import type { GeoPoint, GlobeView } from "../types.ts";
|
|
4
|
+
import { lookAt } from "../view.ts";
|
|
5
|
+
import { type Layer } from "./tiers.ts";
|
|
6
|
+
/** The parts of three.js the Earth uses. A page can hand over a module that re-exports just these (see `setThreeLoader`) instead of all of three. */
|
|
7
|
+
export type ThreeParts = Pick<typeof THREE, "Texture" | "BufferGeometry" | "BufferAttribute" | "ShaderMaterial" | "Group" | "Mesh" | "Scene" | "OrthographicCamera" | "Vector3" | "WebGLRenderer" | "RepeatWrapping" | "ClampToEdgeWrapping" | "LinearMipmapLinearFilter" | "LinearFilter" | "BackSide">;
|
|
8
|
+
type Three = ThreeParts;
|
|
9
|
+
/** The glow reaches this many radii from the centre; the globe at 1x is drawn to fit inside it. */
|
|
10
|
+
export declare const ATMOSPHERE_OUTER = 1.08;
|
|
11
|
+
/** What the scene reads at each draw; the element changes it and asks for a draw. */
|
|
12
|
+
export interface EarthModel {
|
|
13
|
+
/** The home centre; the shared controls' yaw and tilt are added to it as in the flat globe. */
|
|
14
|
+
centre: GeoPoint;
|
|
15
|
+
view: GlobeView;
|
|
16
|
+
/** The moment shown, in milliseconds since the epoch: it fixes where the Sun is and how far the clouds have drifted. */
|
|
17
|
+
time: number;
|
|
18
|
+
clouds: boolean;
|
|
19
|
+
lights: boolean;
|
|
20
|
+
/** Where the camera looked at the last draw (the element decides from it whether a new draw is worth it). */
|
|
21
|
+
drawn: {
|
|
22
|
+
view: GlobeView;
|
|
23
|
+
time: number;
|
|
24
|
+
radius: number;
|
|
25
|
+
centre: GeoPoint;
|
|
26
|
+
} | null;
|
|
27
|
+
/** The pixel ratio the textures are chosen for. */
|
|
28
|
+
ratio(): number;
|
|
29
|
+
}
|
|
30
|
+
export interface EarthDeps {
|
|
31
|
+
assets: AssetCache;
|
|
32
|
+
/** Ends with a slash. */
|
|
33
|
+
textureBase: () => string;
|
|
34
|
+
/** The widest texture, 4096 at most. */
|
|
35
|
+
cap: () => number;
|
|
36
|
+
/** Clouds' eastward drift against the ground, degrees an hour. */
|
|
37
|
+
drift: number;
|
|
38
|
+
invalidate: () => void;
|
|
39
|
+
onError?: (error: unknown) => void;
|
|
40
|
+
onTier?: (layer: Layer, width: number) => void;
|
|
41
|
+
/** Told at every draw where the disc is, for what is laid over it (markers). */
|
|
42
|
+
onPose?: (pose: {
|
|
43
|
+
lon: number;
|
|
44
|
+
lat: number;
|
|
45
|
+
radius: number;
|
|
46
|
+
cx: number;
|
|
47
|
+
cy: number;
|
|
48
|
+
width: number;
|
|
49
|
+
height: number;
|
|
50
|
+
}) => void;
|
|
51
|
+
}
|
|
52
|
+
export declare const newModel: () => EarthModel;
|
|
53
|
+
/** The disc's radius in CSS pixels in a box of `width` x `height` at `zoom`: it fits the glow in the box at 1x. */
|
|
54
|
+
export declare const discRadius: (width: number, height: number, zoom: number) => number;
|
|
55
|
+
export { lookAt };
|
|
56
|
+
export declare function createEarthItem(model: EarthModel, deps: EarthDeps): Item3D<Three>;
|