@johnmorrisdotca/chikyu-earth 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/NOTICE.md +39 -1
  3. package/README.md +199 -24
  4. package/dist/countries.d.ts +11 -0
  5. package/dist/countries.js +11 -0
  6. package/dist/element.d.ts +24 -1
  7. package/dist/element.js +70 -5
  8. package/dist/globe.d.ts +15 -1
  9. package/dist/globe.js +15 -1
  10. package/dist/index.d.ts +12 -1
  11. package/dist/index.js +12 -1
  12. package/dist/markers/cluster.d.ts +38 -0
  13. package/dist/markers/cluster.js +42 -0
  14. package/dist/markers/index.d.ts +5 -0
  15. package/dist/markers/index.js +6 -0
  16. package/dist/markers/labels.d.ts +64 -0
  17. package/dist/markers/labels.js +70 -0
  18. package/dist/markers/layer.d.ts +129 -0
  19. package/dist/markers/layer.js +455 -0
  20. package/dist/markers/project.d.ts +82 -0
  21. package/dist/markers/project.js +93 -0
  22. package/dist/markers/types.d.ts +83 -0
  23. package/dist/markers/types.js +1 -0
  24. package/dist/realistic/earth.d.ts +81 -0
  25. package/dist/realistic/earth.js +163 -0
  26. package/dist/realistic/element.d.ts +93 -0
  27. package/dist/realistic/element.js +284 -0
  28. package/dist/realistic/gate.d.ts +64 -0
  29. package/dist/realistic/gate.js +72 -0
  30. package/dist/realistic/index.d.ts +5 -0
  31. package/dist/realistic/index.js +6 -0
  32. package/dist/realistic/shaders.d.ts +10 -0
  33. package/dist/realistic/shaders.js +124 -0
  34. package/dist/realistic/shared.d.ts +23 -0
  35. package/dist/realistic/shared.js +51 -0
  36. package/dist/realistic/sun.d.ts +44 -0
  37. package/dist/realistic/sun.js +65 -0
  38. package/dist/realistic/textures.d.ts +40 -0
  39. package/dist/realistic/textures.js +141 -0
  40. package/dist/realistic/tiers.d.ts +94 -0
  41. package/dist/realistic/tiers.js +94 -0
  42. package/dist/rotate.d.ts +59 -4
  43. package/dist/rotate.js +59 -4
  44. package/dist/space/element.d.ts +134 -0
  45. package/dist/space/element.js +394 -0
  46. package/dist/space/ephemeris.d.ts +151 -0
  47. package/dist/space/ephemeris.js +162 -0
  48. package/dist/space/index.d.ts +3 -0
  49. package/dist/space/index.js +5 -0
  50. package/dist/space/scale.d.ts +124 -0
  51. package/dist/space/scale.js +141 -0
  52. package/dist/space/scene.d.ts +131 -0
  53. package/dist/space/scene.js +295 -0
  54. package/dist/styles.d.ts +65 -2
  55. package/dist/styles.js +43 -2
  56. package/dist/textures/clouds-1024.webp +0 -0
  57. package/dist/textures/clouds-2048.webp +0 -0
  58. package/dist/textures/day-1024.webp +0 -0
  59. package/dist/textures/day-2048.webp +0 -0
  60. package/dist/textures/day-4096.webp +0 -0
  61. package/dist/textures/moon-1024.webp +0 -0
  62. package/dist/textures/moon-2048.webp +0 -0
  63. package/dist/textures/night-1024.webp +0 -0
  64. package/dist/textures/night-2048.webp +0 -0
  65. package/dist/textures/water-1024.webp +0 -0
  66. package/dist/textures/water-2048.webp +0 -0
  67. package/dist/types.d.ts +55 -5
  68. package/dist/view.d.ts +64 -3
  69. package/dist/view.js +56 -3
  70. package/package.json +93 -8
@@ -0,0 +1,455 @@
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
+ /**
18
+ * Put markers on a globe, for either look: dots drawn in one pass on a canvas, a few labels as real buttons, crowded places gathered into clusters. It draws only when the view or the markers change, reads no layout to place a label, and shows no more labels than `maxLabels`. Hovering, clicking or focusing a marker fires `chikyu-marker-hover`, `chikyu-marker-click` and `chikyu-marker-focus` on the element, with your own `data` in the detail.
19
+ *
20
+ * @param element - `<chikyu-earth>` or `<chikyu-earth-realistic>`.
21
+ * @param options - The most labels, the cluster radius and the default colour; each has a default.
22
+ * @returns The markers: `add`, `addAll`, `update`, `remove`, `clear`, `groups`, `toggleGroup`, `stats` and `detach`.
23
+ *
24
+ * @example
25
+ * ```ts no-run
26
+ * import "@johnmorrisdotca/chikyu-earth/element";
27
+ * import { attachMarkers, type MarkerEventDetail } from "@johnmorrisdotca/chikyu-earth/markers";
28
+ *
29
+ * const earth = document.querySelector("chikyu-earth")!;
30
+ * const markers = attachMarkers(earth);
31
+ * markers.add({ id: "tokyo", lon: 139.69, lat: 35.69, label: "Tokyo", group: "Cities" });
32
+ * earth.addEventListener("chikyu-marker-click", (event) => {
33
+ * const { marker } = (event as CustomEvent<MarkerEventDetail>).detail;
34
+ * console.log("chose", marker?.label);
35
+ * });
36
+ * ```
37
+ */
38
+ export function attachMarkers(element, options = {}) {
39
+ const host = element.overlay;
40
+ const root = host.overlayRoot;
41
+ const doc = root.ownerDocument;
42
+ const maxLabels = options.maxLabels ?? 24;
43
+ const clusterRadius = options.clusterRadius ?? 28;
44
+ const defaultColor = options.color ?? "#6cd0ff";
45
+ const shadow = root.getRootNode();
46
+ const sheet = typeof CSSStyleSheet !== "undefined" && "adoptedStyleSheets" in doc && shadow instanceof ShadowRoot ? new CSSStyleSheet() : null;
47
+ let style = null;
48
+ if (sheet && shadow instanceof ShadowRoot) {
49
+ sheet.replaceSync(CSS);
50
+ shadow.adoptedStyleSheets = [...shadow.adoptedStyleSheets, sheet];
51
+ }
52
+ else {
53
+ style = doc.createElement("style");
54
+ style.textContent = CSS;
55
+ root.append(style);
56
+ }
57
+ const canvas = doc.createElement("canvas");
58
+ canvas.className = "cm-canvas";
59
+ canvas.setAttribute("aria-hidden", "true");
60
+ const labelBox = doc.createElement("div");
61
+ labelBox.className = "cm-labels";
62
+ const list = doc.createElement("ul");
63
+ list.className = "cm-list";
64
+ list.setAttribute("aria-label", "Places marked on the globe");
65
+ root.append(canvas, labelBox, list);
66
+ const ctx = canvas.getContext("2d");
67
+ const store = new Map();
68
+ const hidden = new Set();
69
+ let compiled = null;
70
+ let listDirty = true;
71
+ let renders = 0;
72
+ let last = host.pose;
73
+ let xs = new Float32Array(0);
74
+ let ys = new Float32Array(0);
75
+ let zs = new Float32Array(0);
76
+ const stats = { markers: 0, clusters: 0, labels: 0, renders: 0 };
77
+ // what the last render drew, for picking
78
+ let hitX = [];
79
+ let hitY = [];
80
+ let hitR = [];
81
+ let hitMembers = [];
82
+ let hitHover = null;
83
+ const pool = [];
84
+ let shown = 0;
85
+ const widths = new Map();
86
+ const compile = () => {
87
+ if (compiled)
88
+ return compiled;
89
+ const items = [...store.values()].filter((m) => !m.group || !hidden.has(m.group));
90
+ const lons = new Float64Array(items.length);
91
+ const lats = new Float64Array(items.length);
92
+ const sizes = new Float32Array(items.length);
93
+ items.forEach((m, i) => {
94
+ lons[i] = m.lon;
95
+ lats[i] = m.lat;
96
+ sizes[i] = Math.min(32, Math.max(4, m.size ?? 8));
97
+ });
98
+ compiled = { items, lons, lats, sizes };
99
+ if (xs.length < items.length) {
100
+ xs = new Float32Array(items.length);
101
+ ys = new Float32Array(items.length);
102
+ zs = new Float32Array(items.length);
103
+ }
104
+ return compiled;
105
+ };
106
+ const textWidth = (text) => {
107
+ let w = widths.get(text);
108
+ if (w === undefined) {
109
+ if (ctx) {
110
+ ctx.font = LABEL_FONT;
111
+ w = Math.ceil(ctx.measureText(text).width);
112
+ }
113
+ else
114
+ w = text.length * 7;
115
+ if (widths.size > 2000)
116
+ widths.clear();
117
+ widths.set(text, w);
118
+ }
119
+ return w;
120
+ };
121
+ const rebuildList = () => {
122
+ listDirty = false;
123
+ list.replaceChildren();
124
+ const entries = [...store.values()].filter((m) => !m.group || !hidden.has(m.group));
125
+ const summary = doc.createElement("li");
126
+ summary.textContent = `${entries.length} place${entries.length === 1 ? "" : "s"} marked on the globe`;
127
+ list.append(summary);
128
+ if (entries.length > TEXT_LIST_MAX)
129
+ return;
130
+ for (const m of entries) {
131
+ const li = doc.createElement("li");
132
+ li.textContent = m.description ?? m.label ?? `${m.lat.toFixed(1)}, ${m.lon.toFixed(1)}`;
133
+ list.append(li);
134
+ }
135
+ };
136
+ const drawIcon = (c, icon, x, y, r, color) => {
137
+ c.fillStyle = color;
138
+ c.strokeStyle = "rgba(0,0,0,.6)";
139
+ c.lineWidth = 1;
140
+ c.beginPath();
141
+ if (icon === "square")
142
+ c.rect(x - r, y - r, 2 * r, 2 * r);
143
+ else if (icon === "diamond") {
144
+ c.moveTo(x, y - r * 1.3);
145
+ c.lineTo(x + r * 1.3, y);
146
+ c.lineTo(x, y + r * 1.3);
147
+ c.lineTo(x - r * 1.3, y);
148
+ c.closePath();
149
+ }
150
+ else
151
+ c.arc(x, y, r, 0, Math.PI * 2);
152
+ if (icon === "ring") {
153
+ c.lineWidth = Math.max(1.5, r * 0.35);
154
+ c.strokeStyle = color;
155
+ c.stroke();
156
+ }
157
+ else {
158
+ c.fill();
159
+ c.stroke();
160
+ }
161
+ };
162
+ const render = (pose = last) => {
163
+ if (!pose || !ctx)
164
+ return;
165
+ last = pose;
166
+ renders += 1;
167
+ stats.renders = renders;
168
+ if (listDirty)
169
+ rebuildList();
170
+ root.style.transform = pose.transform;
171
+ const data = compile();
172
+ const ratio = canvasRatio(pose.width, pose.height, globalThis.devicePixelRatio || 1);
173
+ const pw = Math.max(1, Math.round(pose.width * ratio));
174
+ const ph = Math.max(1, Math.round(pose.height * ratio));
175
+ if (canvas.width !== pw || canvas.height !== ph) {
176
+ canvas.width = pw;
177
+ canvas.height = ph;
178
+ }
179
+ ctx.setTransform(pw / pose.width, 0, 0, ph / pose.height, 0, 0);
180
+ ctx.clearRect(0, 0, pose.width, pose.height);
181
+ const n = data.items.length;
182
+ projectAll(data.lons, data.lats, n, pose, xs, ys, zs);
183
+ const visible = [];
184
+ for (let i = 0; i < n; i += 1) {
185
+ if (zs[i] > 0.02 && xs[i] > -EDGE && xs[i] < pose.width + EDGE && ys[i] > -EDGE && ys[i] < pose.height + EDGE)
186
+ visible.push(i);
187
+ }
188
+ const groups = clusterRadius > 0 ? clusterPoints(visible, visible.length, xs, ys, clusterRadius) : visible.map((i) => ({ x: xs[i], y: ys[i], members: [i] }));
189
+ hitX = [];
190
+ hitY = [];
191
+ hitR = [];
192
+ hitMembers = [];
193
+ const items = [];
194
+ const labelled = [];
195
+ let clusters = 0;
196
+ for (const g of groups) {
197
+ if (g.members.length === 1) {
198
+ const i = g.members[0];
199
+ const m = data.items[i];
200
+ const r = data.sizes[i] / 2;
201
+ const edge = zs[i] < 0.15 ? 0.35 : zs[i] < 0.3 ? 0.7 : 1;
202
+ ctx.globalAlpha = edge;
203
+ drawIcon(ctx, m.icon ?? "dot", g.x, g.y, r, m.color ?? defaultColor);
204
+ ctx.globalAlpha = 1;
205
+ hitX.push(g.x);
206
+ hitY.push(g.y);
207
+ hitR.push(Math.max(10, r + 4));
208
+ hitMembers.push([i]);
209
+ if (m.label) {
210
+ items.push({ x: g.x, y: g.y, r, w: textWidth(m.label) + 12, h: 20, priority: m.priority ?? 0 });
211
+ labelled.push(i);
212
+ }
213
+ }
214
+ else {
215
+ clusters += 1;
216
+ const r = 10 + Math.min(10, Math.log2(g.members.length) * 3);
217
+ ctx.fillStyle = "rgba(108,208,255,.85)";
218
+ ctx.strokeStyle = "rgba(0,0,0,.6)";
219
+ ctx.lineWidth = 1;
220
+ ctx.beginPath();
221
+ ctx.arc(g.x, g.y, r, 0, Math.PI * 2);
222
+ ctx.fill();
223
+ ctx.stroke();
224
+ ctx.fillStyle = "#06101c";
225
+ ctx.font = "bold 11px system-ui, sans-serif";
226
+ ctx.textAlign = "center";
227
+ ctx.textBaseline = "middle";
228
+ ctx.fillText(String(g.members.length), g.x, g.y + 0.5);
229
+ hitX.push(g.x);
230
+ hitY.push(g.y);
231
+ hitR.push(r + 2);
232
+ hitMembers.push(g.members);
233
+ }
234
+ }
235
+ const placed = placeLabels(items, pose.width, pose.height, maxLabels);
236
+ for (let k = 0; k < placed.length; k += 1) {
237
+ const p = placed[k];
238
+ const m = data.items[labelled[p.item]];
239
+ let button = pool[k];
240
+ if (!button) {
241
+ button = doc.createElement("button");
242
+ button.type = "button";
243
+ button.className = "cm-label";
244
+ pool[k] = button;
245
+ labelBox.append(button);
246
+ }
247
+ if (button.dataset["id"] !== m.id) {
248
+ button.dataset["id"] = m.id;
249
+ button.textContent = m.label ?? "";
250
+ button.setAttribute("aria-label", m.description ?? m.label ?? m.id);
251
+ }
252
+ if (k >= shown)
253
+ button.hidden = false;
254
+ button.style.transform = `translate(${Math.round(p.left)}px,${Math.round(p.top)}px)`;
255
+ }
256
+ for (let k = placed.length; k < shown; k += 1) {
257
+ const button = pool[k];
258
+ if (button && !button.hidden) {
259
+ button.hidden = true;
260
+ delete button.dataset["id"];
261
+ }
262
+ }
263
+ shown = placed.length;
264
+ stats.markers = visible.length - groups.reduce((sum, g) => sum + (g.members.length > 1 ? g.members.length : 0), 0);
265
+ stats.clusters = clusters;
266
+ stats.labels = placed.length;
267
+ };
268
+ let scheduled = false;
269
+ const changed = () => {
270
+ compiled = null;
271
+ listDirty = true;
272
+ if (scheduled)
273
+ return;
274
+ scheduled = true;
275
+ queueMicrotask(() => {
276
+ scheduled = false;
277
+ render();
278
+ });
279
+ };
280
+ const unsubscribe = host.subscribe((pose) => render(pose));
281
+ // --- events ------------------------------------------------------------------------------------------------------------------------------------
282
+ const detail = (marker, x, y, cluster) => ({ marker, x, y, ...(cluster ? { cluster } : {}) });
283
+ const emit = (type, d) => {
284
+ element.dispatchEvent(new CustomEvent(type, { detail: d, bubbles: true, composed: true }));
285
+ };
286
+ const pick = (x, y) => {
287
+ let best = -1;
288
+ let bestD = Infinity;
289
+ for (let k = 0; k < hitX.length; k += 1) {
290
+ const dx = x - hitX[k];
291
+ const dy = y - hitY[k];
292
+ const d = dx * dx + dy * dy;
293
+ if (d <= hitR[k] * hitR[k] && d < bestD) {
294
+ best = k;
295
+ bestD = d;
296
+ }
297
+ }
298
+ return best;
299
+ };
300
+ const local = (event) => {
301
+ const rect = element.getBoundingClientRect();
302
+ const scale = last && rect.width > 0 ? last.width / rect.width : 1;
303
+ return { x: (event.clientX - rect.left) * scale, y: (event.clientY - rect.top) * scale };
304
+ };
305
+ let moveAt = 0;
306
+ const onMove = (event) => {
307
+ const now = performance.now();
308
+ if (now - moveAt < 33)
309
+ return;
310
+ moveAt = now;
311
+ const { x, y } = local(event);
312
+ const k = pick(x, y);
313
+ const id = k >= 0 && hitMembers[k].length === 1 ? (compile().items[hitMembers[k][0]]?.id ?? null) : null;
314
+ const key = k >= 0 ? (id ?? `cluster:${k}`) : null;
315
+ if (key === hitHover)
316
+ return;
317
+ hitHover = key;
318
+ element.toggleAttribute("data-marker-hover", key !== null);
319
+ 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));
320
+ };
321
+ const onLeave = () => {
322
+ if (hitHover === null)
323
+ return;
324
+ hitHover = null;
325
+ element.removeAttribute("data-marker-hover");
326
+ emit("chikyu-marker-hover", detail(null, -1, -1));
327
+ };
328
+ let downX = 0;
329
+ let downY = 0;
330
+ // 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.
331
+ const onDown = (event) => {
332
+ if (event.button !== 0)
333
+ return;
334
+ downX = event.clientX;
335
+ downY = event.clientY;
336
+ window.addEventListener("pointerup", onUp, { capture: true, once: true });
337
+ };
338
+ const onUp = (event) => {
339
+ if (Math.hypot(event.clientX - downX, event.clientY - downY) > 6)
340
+ return;
341
+ const { x, y } = local(event);
342
+ const k = pick(x, y);
343
+ if (k < 0)
344
+ return;
345
+ const members = hitMembers[k];
346
+ const items = compile().items;
347
+ if (members.length === 1)
348
+ emit("chikyu-marker-click", detail(items[members[0]] ?? null, x, y));
349
+ else
350
+ emit("chikyu-marker-click", detail(null, x, y, members.map((i) => items[i].id)));
351
+ };
352
+ const onLabelClick = (event) => {
353
+ const id = event.target?.dataset?.["id"];
354
+ const m = id ? store.get(id) : undefined;
355
+ if (!m || !last)
356
+ return;
357
+ const p = projectAt(m);
358
+ emit("chikyu-marker-click", detail(m, p.x, p.y));
359
+ };
360
+ const onLabelFocus = (event) => {
361
+ const id = event.target?.dataset?.["id"];
362
+ const m = id ? store.get(id) : undefined;
363
+ if (!m || !last)
364
+ return;
365
+ const p = projectAt(m);
366
+ emit("chikyu-marker-focus", detail(m, p.x, p.y));
367
+ };
368
+ const projectAt = (m) => {
369
+ const k = compile().items.indexOf(m);
370
+ return k >= 0 ? { x: xs[k], y: ys[k] } : { x: 0, y: 0 };
371
+ };
372
+ element.addEventListener("pointermove", onMove);
373
+ element.addEventListener("pointerleave", onLeave);
374
+ element.addEventListener("pointerdown", onDown);
375
+ labelBox.addEventListener("click", onLabelClick);
376
+ labelBox.addEventListener("focusin", onLabelFocus);
377
+ const api = {
378
+ add(marker) {
379
+ store.set(marker.id, { ...marker });
380
+ changed();
381
+ },
382
+ addAll(markers) {
383
+ for (const marker of markers)
384
+ store.set(marker.id, { ...marker });
385
+ changed();
386
+ },
387
+ update(id, patch) {
388
+ const found = store.get(id);
389
+ if (!found)
390
+ return;
391
+ store.set(id, { ...found, ...patch, id });
392
+ changed();
393
+ },
394
+ remove(id) {
395
+ if (store.delete(id))
396
+ changed();
397
+ },
398
+ clear() {
399
+ store.clear();
400
+ changed();
401
+ },
402
+ get: (id) => store.get(id),
403
+ list: () => [...store.values()],
404
+ get size() {
405
+ return store.size;
406
+ },
407
+ groups() {
408
+ const counts = new Map();
409
+ for (const m of store.values())
410
+ if (m.group)
411
+ counts.set(m.group, (counts.get(m.group) ?? 0) + 1);
412
+ for (const name of hidden)
413
+ if (!counts.has(name))
414
+ counts.set(name, 0);
415
+ return [...counts].map(([name, count]) => ({ name, count, visible: !hidden.has(name) }));
416
+ },
417
+ showGroup(name) {
418
+ if (hidden.delete(name))
419
+ changed();
420
+ },
421
+ hideGroup(name) {
422
+ if (!hidden.has(name)) {
423
+ hidden.add(name);
424
+ changed();
425
+ }
426
+ },
427
+ toggleGroup(name, force) {
428
+ const show = force ?? hidden.has(name);
429
+ if (show)
430
+ api.showGroup(name);
431
+ else
432
+ api.hideGroup(name);
433
+ return show;
434
+ },
435
+ stats,
436
+ detach() {
437
+ unsubscribe();
438
+ element.removeEventListener("pointermove", onMove);
439
+ element.removeEventListener("pointerleave", onLeave);
440
+ element.removeEventListener("pointerdown", onDown);
441
+ window.removeEventListener("pointerup", onUp, true);
442
+ labelBox.removeEventListener("click", onLabelClick);
443
+ labelBox.removeEventListener("focusin", onLabelFocus);
444
+ canvas.remove();
445
+ labelBox.remove();
446
+ list.remove();
447
+ style?.remove();
448
+ if (sheet && shadow instanceof ShadowRoot)
449
+ shadow.adoptedStyleSheets = shadow.adoptedStyleSheets.filter((s) => s !== sheet);
450
+ root.style.transform = "";
451
+ },
452
+ };
453
+ render();
454
+ return api;
455
+ }
@@ -0,0 +1,82 @@
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
+ /**
3
+ * The view: the point of the Earth at the middle of the disc, the disc's radius and its centre, in CSS pixels.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * import { project, type Pose } from "@johnmorrisdotca/chikyu-earth/markers";
8
+ *
9
+ * const pose: Pose = { lon: 10, lat: 20, radius: 100, cx: 150, cy: 150 };
10
+ * console.log(project(10, 20, pose));
11
+ * ```
12
+ */
13
+ export interface Pose {
14
+ lon: number;
15
+ lat: number;
16
+ radius: number;
17
+ cx: number;
18
+ cy: number;
19
+ }
20
+ /**
21
+ * 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
22
+ * the viewer, below it is on the far side. North is up.
23
+ *
24
+ * @param lon - Longitude in degrees east.
25
+ * @param lat - Latitude in degrees north.
26
+ * @param pose - The view.
27
+ * @returns The point's x and y in pixels and `z`, the cosine of its angle from the middle of the disc.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * import { project } from "@johnmorrisdotca/chikyu-earth/markers";
32
+ *
33
+ * const where = project(139.69, 35.69, { lon: 139.69, lat: 35.69, radius: 200, cx: 210, cy: 210 });
34
+ * console.log(where.x, where.y, where.z);
35
+ * ```
36
+ */
37
+ export declare function project(lon: number, lat: number, pose: Pose): {
38
+ x: number;
39
+ y: number;
40
+ z: number;
41
+ };
42
+ /**
43
+ * Project many points into flat arrays (reused between calls); `z` is the cosine used for a point's facing test.
44
+ *
45
+ * @param lons - Longitudes in degrees.
46
+ * @param lats - Latitudes in degrees.
47
+ * @param count - How many points to project.
48
+ * @param pose - The view.
49
+ * @param xs - Filled with each point's x in pixels.
50
+ * @param ys - Filled with each point's y in pixels.
51
+ * @param zs - Filled with each point's `z`: above 0 it faces the viewer.
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * import { projectAll } from "@johnmorrisdotca/chikyu-earth/markers";
56
+ *
57
+ * const xs = new Float32Array(2);
58
+ * const ys = new Float32Array(2);
59
+ * const zs = new Float32Array(2);
60
+ * projectAll([0, 180], [0, 0], 2, { lon: 0, lat: 0, radius: 100, cx: 100, cy: 100 }, xs, ys, zs);
61
+ * console.log(zs[0], zs[1]);
62
+ * ```
63
+ */
64
+ export declare function projectAll(lons: ArrayLike<number>, lats: ArrayLike<number>, count: number, pose: Pose, xs: Float32Array, ys: Float32Array, zs: Float32Array): void;
65
+ /**
66
+ * 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).
67
+ *
68
+ * @param width - The canvas's width in CSS pixels.
69
+ * @param height - The canvas's height in CSS pixels.
70
+ * @param deviceRatio - The device's pixel ratio.
71
+ * @param maxSide - The most pixels on a side.
72
+ * @param maxPixels - The most pixels in all.
73
+ * @returns The ratio to draw at, between 0.1 and 2.
74
+ *
75
+ * @example
76
+ * ```ts
77
+ * import { canvasRatio } from "@johnmorrisdotca/chikyu-earth/markers";
78
+ *
79
+ * console.log(canvasRatio(420, 420, 3), canvasRatio(5000, 3000, 2));
80
+ * ```
81
+ */
82
+ export declare function canvasRatio(width: number, height: number, deviceRatio: number, maxSide?: number, maxPixels?: number): number;
@@ -0,0 +1,93 @@
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
+ * @param lon - Longitude in degrees east.
8
+ * @param lat - Latitude in degrees north.
9
+ * @param pose - The view.
10
+ * @returns The point's x and y in pixels and `z`, the cosine of its angle from the middle of the disc.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { project } from "@johnmorrisdotca/chikyu-earth/markers";
15
+ *
16
+ * const where = project(139.69, 35.69, { lon: 139.69, lat: 35.69, radius: 200, cx: 210, cy: 210 });
17
+ * console.log(where.x, where.y, where.z);
18
+ * ```
19
+ */
20
+ export function project(lon, lat, pose) {
21
+ const phi = lat * RAD;
22
+ const phi0 = pose.lat * RAD;
23
+ const delta = (lon - pose.lon) * RAD;
24
+ const cosPhi = Math.cos(phi);
25
+ const z = Math.sin(phi0) * Math.sin(phi) + Math.cos(phi0) * cosPhi * Math.cos(delta);
26
+ return {
27
+ x: pose.cx + pose.radius * cosPhi * Math.sin(delta),
28
+ y: pose.cy - pose.radius * (Math.cos(phi0) * Math.sin(phi) - Math.sin(phi0) * cosPhi * Math.cos(delta)),
29
+ z,
30
+ };
31
+ }
32
+ /**
33
+ * Project many points into flat arrays (reused between calls); `z` is the cosine used for a point's facing test.
34
+ *
35
+ * @param lons - Longitudes in degrees.
36
+ * @param lats - Latitudes in degrees.
37
+ * @param count - How many points to project.
38
+ * @param pose - The view.
39
+ * @param xs - Filled with each point's x in pixels.
40
+ * @param ys - Filled with each point's y in pixels.
41
+ * @param zs - Filled with each point's `z`: above 0 it faces the viewer.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * import { projectAll } from "@johnmorrisdotca/chikyu-earth/markers";
46
+ *
47
+ * const xs = new Float32Array(2);
48
+ * const ys = new Float32Array(2);
49
+ * const zs = new Float32Array(2);
50
+ * projectAll([0, 180], [0, 0], 2, { lon: 0, lat: 0, radius: 100, cx: 100, cy: 100 }, xs, ys, zs);
51
+ * console.log(zs[0], zs[1]);
52
+ * ```
53
+ */
54
+ export function projectAll(lons, lats, count, pose, xs, ys, zs) {
55
+ const phi0 = pose.lat * RAD;
56
+ const sin0 = Math.sin(phi0);
57
+ const cos0 = Math.cos(phi0);
58
+ for (let i = 0; i < count; i += 1) {
59
+ const phi = lats[i] * RAD;
60
+ const delta = (lons[i] - pose.lon) * RAD;
61
+ const sinPhi = Math.sin(phi);
62
+ const cosPhi = Math.cos(phi);
63
+ const cosDelta = Math.cos(delta);
64
+ zs[i] = sin0 * sinPhi + cos0 * cosPhi * cosDelta;
65
+ xs[i] = pose.cx + pose.radius * cosPhi * Math.sin(delta);
66
+ ys[i] = pose.cy - pose.radius * (cos0 * sinPhi - sin0 * cosPhi * cosDelta);
67
+ }
68
+ }
69
+ /**
70
+ * 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).
71
+ *
72
+ * @param width - The canvas's width in CSS pixels.
73
+ * @param height - The canvas's height in CSS pixels.
74
+ * @param deviceRatio - The device's pixel ratio.
75
+ * @param maxSide - The most pixels on a side.
76
+ * @param maxPixels - The most pixels in all.
77
+ * @returns The ratio to draw at, between 0.1 and 2.
78
+ *
79
+ * @example
80
+ * ```ts
81
+ * import { canvasRatio } from "@johnmorrisdotca/chikyu-earth/markers";
82
+ *
83
+ * console.log(canvasRatio(420, 420, 3), canvasRatio(5000, 3000, 2));
84
+ * ```
85
+ */
86
+ export function canvasRatio(width, height, deviceRatio, maxSide = 4096, maxPixels = 6_000_000) {
87
+ let ratio = Math.min(Math.max(deviceRatio, 1), 2);
88
+ ratio = Math.min(ratio, maxSide / Math.max(width, 1), maxSide / Math.max(height, 1));
89
+ const pixels = width * height * ratio * ratio;
90
+ if (pixels > maxPixels)
91
+ ratio *= Math.sqrt(maxPixels / pixels);
92
+ return Math.max(0.1, ratio);
93
+ }