@johnmorrisdotca/chikyu-earth 0.3.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 +56 -0
- package/NOTICE.md +34 -6
- package/README.md +41 -8
- package/dist/{data/countries.d.ts → countries.d.ts} +1 -1
- package/dist/countries.js +15 -0
- package/dist/element.d.ts +6 -1
- package/dist/element.js +55 -3
- package/dist/globe.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- 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 +48 -14
- package/dist/data/countries.js +0 -1
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { HOME_VIEW, lookAt } from "../view.js";
|
|
2
|
+
import { ATMOSPHERE_FRAG, ATMOSPHERE_VERT, CLOUD_FRAG, SURFACE_FRAG, SURFACE_VERT } from "./shaders.js";
|
|
3
|
+
import { subsolarPoint, unitVector } from "./sun.js";
|
|
4
|
+
import { chooseTier, TIERS } from "./tiers.js";
|
|
5
|
+
const RAD = Math.PI / 180;
|
|
6
|
+
/** The glow reaches this many radii from the centre; the globe at 1x is drawn to fit inside it. */
|
|
7
|
+
export const ATMOSPHERE_OUTER = 1.08;
|
|
8
|
+
const CLOUD_RADIUS = 1.006;
|
|
9
|
+
const LAYERS = ["day", "night", "clouds", "water"];
|
|
10
|
+
/** Milliseconds a lower texture size must be enough before the larger one is let go. */
|
|
11
|
+
const DOWNGRADE_AFTER_MS = 4000;
|
|
12
|
+
export const newModel = () => ({
|
|
13
|
+
centre: { lon: 0, lat: 0 },
|
|
14
|
+
view: { ...HOME_VIEW },
|
|
15
|
+
time: Date.now(),
|
|
16
|
+
clouds: true,
|
|
17
|
+
lights: true,
|
|
18
|
+
drawn: null,
|
|
19
|
+
ratio: () => Math.min(globalThis.devicePixelRatio || 1, 2),
|
|
20
|
+
});
|
|
21
|
+
/** 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. */
|
|
22
|
+
export const discRadius = (width, height, zoom) => Math.max(1, (Math.min(width, height) / 2 / ATMOSPHERE_OUTER - 1) * zoom);
|
|
23
|
+
export { lookAt };
|
|
24
|
+
/** An equirectangular sphere: +z is longitude 0 on the equator, +x east, +y north; v is 0 at the north pole (the top row of the picture). */
|
|
25
|
+
function sphere(three, columns, rows) {
|
|
26
|
+
const positions = new Float32Array((columns + 1) * (rows + 1) * 3);
|
|
27
|
+
const uvs = new Float32Array((columns + 1) * (rows + 1) * 2);
|
|
28
|
+
let p = 0;
|
|
29
|
+
let q = 0;
|
|
30
|
+
for (let j = 0; j <= rows; j += 1) {
|
|
31
|
+
const lat = (0.5 - j / rows) * Math.PI;
|
|
32
|
+
for (let i = 0; i <= columns; i += 1) {
|
|
33
|
+
const lon = (i / columns - 0.5) * 2 * Math.PI;
|
|
34
|
+
positions[p++] = Math.cos(lat) * Math.sin(lon);
|
|
35
|
+
positions[p++] = Math.sin(lat);
|
|
36
|
+
positions[p++] = Math.cos(lat) * Math.cos(lon);
|
|
37
|
+
uvs[q++] = i / columns;
|
|
38
|
+
uvs[q++] = j / rows;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const index = [];
|
|
42
|
+
for (let j = 0; j < rows; j += 1) {
|
|
43
|
+
for (let i = 0; i < columns; i += 1) {
|
|
44
|
+
const a = j * (columns + 1) + i;
|
|
45
|
+
const b = a + columns + 1;
|
|
46
|
+
index.push(a, b, a + 1, b, b + 1, a + 1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const geometry = new three.BufferGeometry();
|
|
50
|
+
geometry.setAttribute("position", new three.BufferAttribute(positions, 3));
|
|
51
|
+
geometry.setAttribute("uv", new three.BufferAttribute(uvs, 2));
|
|
52
|
+
geometry.setIndex(index);
|
|
53
|
+
return geometry;
|
|
54
|
+
}
|
|
55
|
+
export function createEarthItem(model, deps) {
|
|
56
|
+
return {
|
|
57
|
+
key: "chikyu-earth:realistic",
|
|
58
|
+
async build({ three, width, height }) {
|
|
59
|
+
let size = { width, height };
|
|
60
|
+
let disposed = false;
|
|
61
|
+
const held = [];
|
|
62
|
+
const makeTexture = (layer, texels) => {
|
|
63
|
+
const key = `chikyu-earth:${layer}:${texels}`;
|
|
64
|
+
const entry = deps.assets.acquire(key, () => {
|
|
65
|
+
const texture = new three.Texture();
|
|
66
|
+
texture.wrapS = three.RepeatWrapping;
|
|
67
|
+
texture.wrapT = three.ClampToEdgeWrapping;
|
|
68
|
+
texture.minFilter = three.LinearMipmapLinearFilter;
|
|
69
|
+
texture.magFilter = three.LinearFilter;
|
|
70
|
+
texture.anisotropy = 8;
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
const made = { width: texels, texture, closed: false, ready: Promise.resolve(), release: () => undefined };
|
|
73
|
+
made.ready = fetch(`${deps.textureBase()}${layer}-${texels}.webp`, { signal: controller.signal })
|
|
74
|
+
.then((response) => {
|
|
75
|
+
if (!response.ok)
|
|
76
|
+
throw new Error(`${layer}-${texels}.webp: ${response.status}`);
|
|
77
|
+
return response.blob();
|
|
78
|
+
})
|
|
79
|
+
.then((blob) => createImageBitmap(blob, { premultiplyAlpha: "none", colorSpaceConversion: "none" }))
|
|
80
|
+
.then((bitmap) => {
|
|
81
|
+
if (made.closed)
|
|
82
|
+
bitmap.close();
|
|
83
|
+
else {
|
|
84
|
+
texture.image = bitmap;
|
|
85
|
+
texture.needsUpdate = true;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
made.ready.catch(() => undefined);
|
|
89
|
+
made.release = () => {
|
|
90
|
+
made.closed = true;
|
|
91
|
+
controller.abort();
|
|
92
|
+
texture.dispose();
|
|
93
|
+
texture.image?.close?.();
|
|
94
|
+
};
|
|
95
|
+
return made;
|
|
96
|
+
}, (value) => value.release());
|
|
97
|
+
held.push(key);
|
|
98
|
+
return entry;
|
|
99
|
+
};
|
|
100
|
+
const letGo = (layer, texels) => {
|
|
101
|
+
const key = `chikyu-earth:${layer}:${texels}`;
|
|
102
|
+
const at = held.indexOf(key);
|
|
103
|
+
if (at >= 0)
|
|
104
|
+
held.splice(at, 1);
|
|
105
|
+
deps.assets.release(key);
|
|
106
|
+
};
|
|
107
|
+
const slots = new Map();
|
|
108
|
+
for (const layer of LAYERS) {
|
|
109
|
+
const baseWidth = TIERS[layer][0];
|
|
110
|
+
const base = makeTexture(layer, baseWidth);
|
|
111
|
+
slots.set(layer, { layer, uniform: { value: null }, base, baseWidth, entry: base, width: baseWidth, loading: undefined, timer: undefined });
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
await Promise.all([...slots.values()].map((slot) => slot.base.ready));
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
for (const slot of slots.values())
|
|
118
|
+
letGo(slot.layer, slot.baseWidth);
|
|
119
|
+
deps.onError?.(error);
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
for (const slot of slots.values())
|
|
123
|
+
slot.uniform.value = slot.base.texture;
|
|
124
|
+
const sunDir = { value: new three.Vector3(1, 0, 0) };
|
|
125
|
+
const grid = sphere(three, 128, 64);
|
|
126
|
+
const surface = new three.ShaderMaterial({
|
|
127
|
+
vertexShader: SURFACE_VERT,
|
|
128
|
+
fragmentShader: SURFACE_FRAG,
|
|
129
|
+
uniforms: { dayMap: slots.get("day").uniform, nightMap: slots.get("night").uniform, waterMap: slots.get("water").uniform, sunDir, lights: { value: 1 } },
|
|
130
|
+
});
|
|
131
|
+
const cloudShift = { value: 0 };
|
|
132
|
+
const cloudMaterial = new three.ShaderMaterial({
|
|
133
|
+
vertexShader: SURFACE_VERT,
|
|
134
|
+
fragmentShader: CLOUD_FRAG,
|
|
135
|
+
uniforms: { cloudMap: slots.get("clouds").uniform, sunDir, shift: cloudShift, opacity: { value: 0.9 } },
|
|
136
|
+
transparent: true,
|
|
137
|
+
depthWrite: false,
|
|
138
|
+
});
|
|
139
|
+
const atmosphere = new three.ShaderMaterial({
|
|
140
|
+
vertexShader: ATMOSPHERE_VERT,
|
|
141
|
+
fragmentShader: ATMOSPHERE_FRAG,
|
|
142
|
+
uniforms: { sunDir, outer: { value: ATMOSPHERE_OUTER } },
|
|
143
|
+
transparent: true,
|
|
144
|
+
depthWrite: false,
|
|
145
|
+
side: three.BackSide,
|
|
146
|
+
});
|
|
147
|
+
const turned = new three.Group();
|
|
148
|
+
const earth = new three.Mesh(grid, surface);
|
|
149
|
+
const clouds = new three.Mesh(grid, cloudMaterial);
|
|
150
|
+
clouds.scale.setScalar(CLOUD_RADIUS);
|
|
151
|
+
clouds.renderOrder = 2;
|
|
152
|
+
turned.add(earth, clouds);
|
|
153
|
+
const glow = new three.Mesh(grid, atmosphere);
|
|
154
|
+
glow.scale.setScalar(ATMOSPHERE_OUTER);
|
|
155
|
+
glow.renderOrder = 1;
|
|
156
|
+
glow.frustumCulled = false;
|
|
157
|
+
const scene = new three.Scene();
|
|
158
|
+
scene.add(turned, glow);
|
|
159
|
+
const camera = new three.OrthographicCamera(-1, 1, 1, -1, 0.1, 20);
|
|
160
|
+
camera.position.set(0, 0, 5);
|
|
161
|
+
const setUniformTexture = (slot, entry, texels) => {
|
|
162
|
+
const previous = slot.entry;
|
|
163
|
+
slot.entry = entry;
|
|
164
|
+
slot.width = texels;
|
|
165
|
+
slot.uniform.value = entry.texture;
|
|
166
|
+
if (previous !== slot.base)
|
|
167
|
+
letGo(slot.layer, previous.width);
|
|
168
|
+
deps.onTier?.(slot.layer, texels);
|
|
169
|
+
};
|
|
170
|
+
const moveTo = (slot, texels) => {
|
|
171
|
+
if (disposed || texels === slot.width || slot.loading === texels)
|
|
172
|
+
return;
|
|
173
|
+
if (texels === slot.baseWidth) {
|
|
174
|
+
setUniformTexture(slot, slot.base, texels);
|
|
175
|
+
deps.invalidate();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
slot.loading = texels;
|
|
179
|
+
const entry = makeTexture(slot.layer, texels);
|
|
180
|
+
entry.ready.then(() => {
|
|
181
|
+
if (disposed || slot.loading !== texels) {
|
|
182
|
+
letGo(slot.layer, texels);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
slot.loading = undefined;
|
|
186
|
+
setUniformTexture(slot, entry, texels);
|
|
187
|
+
deps.invalidate();
|
|
188
|
+
}, (error) => {
|
|
189
|
+
letGo(slot.layer, texels);
|
|
190
|
+
if (slot.loading === texels)
|
|
191
|
+
slot.loading = undefined;
|
|
192
|
+
deps.onError?.(error);
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
const syncTiers = (radiusCssPx) => {
|
|
196
|
+
const radius = radiusCssPx * model.ratio();
|
|
197
|
+
for (const slot of slots.values()) {
|
|
198
|
+
const wanted = chooseTier(slot.layer, radius, deps.cap());
|
|
199
|
+
if (slot.timer !== undefined && wanted >= slot.width) {
|
|
200
|
+
clearTimeout(slot.timer);
|
|
201
|
+
slot.timer = undefined;
|
|
202
|
+
}
|
|
203
|
+
if (wanted > slot.width)
|
|
204
|
+
moveTo(slot, wanted);
|
|
205
|
+
else if (wanted < slot.width && slot.timer === undefined) {
|
|
206
|
+
slot.timer = setTimeout(() => {
|
|
207
|
+
slot.timer = undefined;
|
|
208
|
+
const now = chooseTier(slot.layer, discRadius(size.width, size.height, model.view.zoom) * model.ratio(), deps.cap());
|
|
209
|
+
if (now < slot.width)
|
|
210
|
+
moveTo(slot, now);
|
|
211
|
+
}, DOWNGRADE_AFTER_MS);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
return {
|
|
216
|
+
scene,
|
|
217
|
+
camera,
|
|
218
|
+
update() {
|
|
219
|
+
const view = model.view;
|
|
220
|
+
const look = lookAt(model.centre, view);
|
|
221
|
+
turned.rotation.set(look.lat * RAD, -look.lon * RAD, 0, "XYZ");
|
|
222
|
+
const [sx, sy, sz] = unitVector(subsolarPoint(model.time));
|
|
223
|
+
sunDir.value.set(sx, sy, sz).applyEuler(turned.rotation);
|
|
224
|
+
cloudShift.value = -(((model.time / 3_600_000) * deps.drift) % 360) / 360;
|
|
225
|
+
clouds.visible = model.clouds;
|
|
226
|
+
surface.uniforms["lights"].value = model.lights ? 1 : 0;
|
|
227
|
+
const radius = discRadius(size.width, size.height, view.zoom);
|
|
228
|
+
camera.left = (-size.width / 2 - view.panX) / radius;
|
|
229
|
+
camera.right = (size.width / 2 - view.panX) / radius;
|
|
230
|
+
camera.top = (size.height / 2 + view.panY) / radius;
|
|
231
|
+
camera.bottom = (-size.height / 2 + view.panY) / radius;
|
|
232
|
+
camera.updateProjectionMatrix();
|
|
233
|
+
model.drawn = { view: { ...view }, time: model.time, radius, centre: { ...model.centre } };
|
|
234
|
+
syncTiers(radius);
|
|
235
|
+
deps.onPose?.({ lon: look.lon, lat: look.lat, radius, cx: size.width / 2 + view.panX, cy: size.height / 2 + view.panY, width: size.width, height: size.height });
|
|
236
|
+
return false;
|
|
237
|
+
},
|
|
238
|
+
resize(next, nextHeight) {
|
|
239
|
+
size = { width: next, height: nextHeight };
|
|
240
|
+
},
|
|
241
|
+
dispose() {
|
|
242
|
+
disposed = true;
|
|
243
|
+
for (const slot of slots.values())
|
|
244
|
+
if (slot.timer !== undefined)
|
|
245
|
+
clearTimeout(slot.timer);
|
|
246
|
+
for (const key of held.splice(0))
|
|
247
|
+
deps.assets.release(key);
|
|
248
|
+
grid.dispose();
|
|
249
|
+
surface.dispose();
|
|
250
|
+
cloudMaterial.dispose();
|
|
251
|
+
atmosphere.dispose();
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { GeoPoint, GlobeView } from "../types.ts";
|
|
2
|
+
import { type ThreeParts } from "./earth.ts";
|
|
3
|
+
import type { OverlayHost } from "../markers/layer.ts";
|
|
4
|
+
import { type Layer } from "./tiers.ts";
|
|
5
|
+
type Three = ThreeParts;
|
|
6
|
+
export type EarthState = "loading" | "live" | "unavailable" | "lost" | "idle";
|
|
7
|
+
/** A `time` attribute: "now" (or none) follows the clock, else an ISO date such as 2026-06-21T12:00:00Z. */
|
|
8
|
+
export declare const readTime: (value: string | null) => number | "now";
|
|
9
|
+
/** Where three.js comes from, if a page wants to hand it over itself (before the first element connects). */
|
|
10
|
+
export declare function setThreeLoader(load: () => Promise<Three>): void;
|
|
11
|
+
export declare class ChikyuEarthRealistic extends HTMLElement {
|
|
12
|
+
#private;
|
|
13
|
+
static observedAttributes: string[];
|
|
14
|
+
connectedCallback(): void;
|
|
15
|
+
disconnectedCallback(): void;
|
|
16
|
+
attributeChangedCallback(name: string, _old: string | null, value: string | null): void;
|
|
17
|
+
/** A box laid over the Earth for markers (see `@johnmorrisdotca/chikyu-earth/markers`): where it is, the pose of the last draw, and a way to hear the next. */
|
|
18
|
+
get overlay(): OverlayHost;
|
|
19
|
+
/** Where the view starts: longitude and latitude; the shared controls' yaw and tilt turn it from there. */
|
|
20
|
+
get centre(): GeoPoint;
|
|
21
|
+
set centre(value: GeoPoint);
|
|
22
|
+
/** The moment shown: milliseconds since the epoch, or "now" to follow the clock. */
|
|
23
|
+
get time(): number | "now";
|
|
24
|
+
set time(value: number | Date | "now");
|
|
25
|
+
get clouds(): boolean;
|
|
26
|
+
set clouds(value: boolean);
|
|
27
|
+
get lights(): boolean;
|
|
28
|
+
set lights(value: boolean);
|
|
29
|
+
/** What the element is doing, for a page or a test: its state, the texture widths held, the draws made. */
|
|
30
|
+
get stats(): {
|
|
31
|
+
state: EarthState;
|
|
32
|
+
tiers: Record<Layer, number>;
|
|
33
|
+
draws: number;
|
|
34
|
+
contexts: number;
|
|
35
|
+
canvas: {
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
pixels: number;
|
|
39
|
+
};
|
|
40
|
+
assets: {
|
|
41
|
+
entries: number;
|
|
42
|
+
refs: number;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Apply a shared view controller's camera (yaw, tilt, zoom, pan). A change under half a pixel is not drawn, a settled change always is,
|
|
47
|
+
* and draws are at most 30 a second.
|
|
48
|
+
*/
|
|
49
|
+
setView(view: GlobeView, final?: boolean): void;
|
|
50
|
+
/** The view as it is (for a page that wants to read it back). */
|
|
51
|
+
get view(): GlobeView;
|
|
52
|
+
}
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// <chikyu-earth-realistic>: the Earth from space as a web component, drawn by kazoku-ui's shared 3D renderer (one WebGL context for the
|
|
2
|
+
// page) with three.js, which is the page's own. Optional entry point: the flat `<chikyu-earth>` needs neither. It draws only when something
|
|
3
|
+
// changes, at most 30 times a second, and nothing at all while it is out of sight or the tab is hidden.
|
|
4
|
+
import { createSharedRenderer } from "@johnmorrisdotca/kazoku-ui/renderer";
|
|
5
|
+
import { createEarthItem, lookAt, newModel } from "./earth.js";
|
|
6
|
+
import { createGate, frameCap } from "./gate.js";
|
|
7
|
+
import { MAX_TEXTURE, TIERS } from "./tiers.js";
|
|
8
|
+
const RAD = Math.PI / 180;
|
|
9
|
+
/** The Sun and the clouds are re-placed this often while the time is "now": 15 degrees an hour is 0.25 degree a minute. */
|
|
10
|
+
const LIVE_TICK_MS = 60_000;
|
|
11
|
+
const MIN_TICK_MS = 1000;
|
|
12
|
+
const readCentre = (value) => {
|
|
13
|
+
if (!value)
|
|
14
|
+
return null;
|
|
15
|
+
const [lon, lat] = value.split(",").map((part) => Number(part.trim()));
|
|
16
|
+
if (!Number.isFinite(lon) || !Number.isFinite(lat) || Math.abs(lon) > 180 || Math.abs(lat) > 90)
|
|
17
|
+
return null;
|
|
18
|
+
return { lon: lon, lat: lat };
|
|
19
|
+
};
|
|
20
|
+
/** A `time` attribute: "now" (or none) follows the clock, else an ISO date such as 2026-06-21T12:00:00Z. */
|
|
21
|
+
export const readTime = (value) => {
|
|
22
|
+
if (value === null || value === "" || value === "now")
|
|
23
|
+
return "now";
|
|
24
|
+
const ms = Date.parse(value);
|
|
25
|
+
return Number.isFinite(ms) ? ms : "now";
|
|
26
|
+
};
|
|
27
|
+
// --- one renderer for the page ----------------------------------------------------------------------------------------------------------------------
|
|
28
|
+
let shared;
|
|
29
|
+
let loader = () => import("three");
|
|
30
|
+
const contextListeners = new Set();
|
|
31
|
+
let elements = 0;
|
|
32
|
+
let releaseTimer;
|
|
33
|
+
/** Where three.js comes from, if a page wants to hand it over itself (before the first element connects). */
|
|
34
|
+
export function setThreeLoader(load) {
|
|
35
|
+
loader = load;
|
|
36
|
+
}
|
|
37
|
+
function renderer() {
|
|
38
|
+
shared ??= createSharedRenderer({
|
|
39
|
+
load: () => loader(),
|
|
40
|
+
onContext: (state) => {
|
|
41
|
+
for (const listener of contextListeners)
|
|
42
|
+
listener(state);
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
return shared;
|
|
46
|
+
}
|
|
47
|
+
export class ChikyuEarthRealistic extends HTMLElement {
|
|
48
|
+
static observedAttributes = ["center", "time", "clouds", "lights", "live-seconds"];
|
|
49
|
+
#model = newModel();
|
|
50
|
+
#mode = "now";
|
|
51
|
+
#view;
|
|
52
|
+
#gate;
|
|
53
|
+
#tick;
|
|
54
|
+
#state = "loading";
|
|
55
|
+
#tiers = { day: TIERS.day[0], night: TIERS.night[0], clouds: TIERS.clouds[0], water: TIERS.water[0] };
|
|
56
|
+
#stage;
|
|
57
|
+
#overlay;
|
|
58
|
+
#pose = null;
|
|
59
|
+
#listeners = new Set();
|
|
60
|
+
#onContext = (state) => {
|
|
61
|
+
if (state === "unavailable")
|
|
62
|
+
this.#fail("WebGL is not available");
|
|
63
|
+
};
|
|
64
|
+
// The canvas's own events reach the stage in the capture phase (they do not bubble); the shared renderer handles them and this follows.
|
|
65
|
+
#lost = () => this.#set("lost");
|
|
66
|
+
#restored = () => this.#set("live");
|
|
67
|
+
connectedCallback() {
|
|
68
|
+
clearTimeout(releaseTimer);
|
|
69
|
+
elements += 1;
|
|
70
|
+
this.#model.centre = readCentre(this.getAttribute("center")) ?? this.#model.centre;
|
|
71
|
+
this.#mode = readTime(this.getAttribute("time"));
|
|
72
|
+
this.#model.time = this.#mode === "now" ? Date.now() : this.#mode;
|
|
73
|
+
this.#model.clouds = this.getAttribute("clouds") !== "off";
|
|
74
|
+
this.#model.lights = this.getAttribute("lights") !== "off";
|
|
75
|
+
if (!this.hasAttribute("role"))
|
|
76
|
+
this.setAttribute("role", "img");
|
|
77
|
+
if (!this.hasAttribute("aria-label"))
|
|
78
|
+
this.setAttribute("aria-label", "The Earth from space, with its sunlit and dark sides");
|
|
79
|
+
const root = this.shadowRoot ?? this.attachShadow({ mode: "open" });
|
|
80
|
+
root.innerHTML = `<style>:host{display:block;aspect-ratio:1/1;position:relative}#stage,#overlay{position:absolute;inset:0}#overlay{pointer-events:none}</style><div id="stage"></div><div id="overlay"></div>`;
|
|
81
|
+
this.#stage = root.getElementById("stage");
|
|
82
|
+
this.#overlay = root.getElementById("overlay");
|
|
83
|
+
this.#stage.addEventListener("webglcontextlost", this.#lost, true);
|
|
84
|
+
this.#stage.addEventListener("webglcontextrestored", this.#restored, true);
|
|
85
|
+
const nav = navigator;
|
|
86
|
+
const cap = frameCap(30, { deviceMemory: nav.deviceMemory, hardwareConcurrency: nav.hardwareConcurrency });
|
|
87
|
+
this.#gate = createGate(1000 / cap, () => this.#view?.invalidate());
|
|
88
|
+
nav.getBattery?.().then((battery) => {
|
|
89
|
+
if (!battery.charging && battery.level <= 0.2)
|
|
90
|
+
this.#gate?.setMinMs(1000 / Math.min(cap, 15));
|
|
91
|
+
}, () => undefined);
|
|
92
|
+
contextListeners.add(this.#onContext);
|
|
93
|
+
const item = createEarthItem(this.#model, {
|
|
94
|
+
assets: renderer().assets,
|
|
95
|
+
textureBase: () => this.#textureBase(),
|
|
96
|
+
cap: () => this.#textureCap(),
|
|
97
|
+
drift: 1.5,
|
|
98
|
+
invalidate: () => this.#gate?.request(),
|
|
99
|
+
onPose: (pose) => {
|
|
100
|
+
this.#pose = { ...pose, transform: "" };
|
|
101
|
+
for (const listener of this.#listeners)
|
|
102
|
+
listener(this.#pose);
|
|
103
|
+
},
|
|
104
|
+
onError: (error) => this.#fail(error instanceof Error ? error.message : String(error)),
|
|
105
|
+
onTier: (layer, width) => {
|
|
106
|
+
this.#tiers[layer] = width;
|
|
107
|
+
this.dispatchEvent(new CustomEvent("chikyu-tier", { detail: { layer, width } }));
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
this.#view = renderer().view(this.#stage, item, { still: null });
|
|
111
|
+
this.#set("loading");
|
|
112
|
+
void this.#view.focus().then((live) => {
|
|
113
|
+
if (live) {
|
|
114
|
+
this.#set("live");
|
|
115
|
+
this.dispatchEvent(new CustomEvent("chikyu-ready"));
|
|
116
|
+
}
|
|
117
|
+
else if (this.#state === "loading")
|
|
118
|
+
this.#set(this.#view?.live === false && renderer().focused !== undefined ? "idle" : "unavailable");
|
|
119
|
+
});
|
|
120
|
+
this.addEventListener("pointerdown", this.#take);
|
|
121
|
+
this.addEventListener("focusin", this.#take);
|
|
122
|
+
this.#startTick();
|
|
123
|
+
this.#locate();
|
|
124
|
+
}
|
|
125
|
+
disconnectedCallback() {
|
|
126
|
+
this.removeEventListener("pointerdown", this.#take);
|
|
127
|
+
this.removeEventListener("focusin", this.#take);
|
|
128
|
+
clearInterval(this.#tick);
|
|
129
|
+
this.#tick = undefined;
|
|
130
|
+
this.#gate?.cancel();
|
|
131
|
+
this.#view?.dispose();
|
|
132
|
+
this.#view = undefined;
|
|
133
|
+
contextListeners.delete(this.#onContext);
|
|
134
|
+
elements -= 1;
|
|
135
|
+
if (elements <= 0) {
|
|
136
|
+
releaseTimer = setTimeout(() => {
|
|
137
|
+
if (elements <= 0) {
|
|
138
|
+
shared?.dispose();
|
|
139
|
+
shared = undefined;
|
|
140
|
+
}
|
|
141
|
+
}, 1000);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
attributeChangedCallback(name, _old, value) {
|
|
145
|
+
if (name === "center")
|
|
146
|
+
this.centre = readCentre(value) ?? { lon: 0, lat: 0 };
|
|
147
|
+
else if (name === "time")
|
|
148
|
+
this.time = readTime(value);
|
|
149
|
+
else if (name === "clouds")
|
|
150
|
+
this.clouds = value !== "off";
|
|
151
|
+
else if (name === "lights")
|
|
152
|
+
this.lights = value !== "off";
|
|
153
|
+
else if (name === "live-seconds")
|
|
154
|
+
this.#startTick();
|
|
155
|
+
}
|
|
156
|
+
/** A box laid over the Earth for markers (see `@johnmorrisdotca/chikyu-earth/markers`): where it is, the pose of the last draw, and a way to hear the next. */
|
|
157
|
+
get overlay() {
|
|
158
|
+
if (!this.#overlay)
|
|
159
|
+
throw new Error("chikyu-earth-realistic: attach markers once the element is in the page");
|
|
160
|
+
return {
|
|
161
|
+
overlayRoot: this.#overlay,
|
|
162
|
+
pose: this.#pose,
|
|
163
|
+
subscribe: (listener) => {
|
|
164
|
+
this.#listeners.add(listener);
|
|
165
|
+
return () => this.#listeners.delete(listener);
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/** Where the view starts: longitude and latitude; the shared controls' yaw and tilt turn it from there. */
|
|
170
|
+
get centre() {
|
|
171
|
+
return { ...this.#model.centre };
|
|
172
|
+
}
|
|
173
|
+
set centre(value) {
|
|
174
|
+
this.#model.centre = { ...value };
|
|
175
|
+
this.#gate?.request();
|
|
176
|
+
}
|
|
177
|
+
/** The moment shown: milliseconds since the epoch, or "now" to follow the clock. */
|
|
178
|
+
get time() {
|
|
179
|
+
return this.#mode;
|
|
180
|
+
}
|
|
181
|
+
set time(value) {
|
|
182
|
+
this.#mode = value instanceof Date ? value.getTime() : value;
|
|
183
|
+
if (this.#mode === "now")
|
|
184
|
+
this.#model.time = Date.now();
|
|
185
|
+
else
|
|
186
|
+
this.#model.time = this.#mode;
|
|
187
|
+
this.#startTick();
|
|
188
|
+
this.#gate?.request();
|
|
189
|
+
}
|
|
190
|
+
get clouds() {
|
|
191
|
+
return this.#model.clouds;
|
|
192
|
+
}
|
|
193
|
+
set clouds(value) {
|
|
194
|
+
this.#model.clouds = value;
|
|
195
|
+
this.#gate?.request();
|
|
196
|
+
}
|
|
197
|
+
get lights() {
|
|
198
|
+
return this.#model.lights;
|
|
199
|
+
}
|
|
200
|
+
set lights(value) {
|
|
201
|
+
this.#model.lights = value;
|
|
202
|
+
this.#gate?.request();
|
|
203
|
+
}
|
|
204
|
+
/** What the element is doing, for a page or a test: its state, the texture widths held, the draws made. */
|
|
205
|
+
get stats() {
|
|
206
|
+
const stats = renderer().stats;
|
|
207
|
+
return { state: this.#state, tiers: { ...this.#tiers }, draws: stats.draws, contexts: stats.contexts, canvas: stats.canvas, assets: { entries: stats.assets.entries, refs: stats.assets.refs } };
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Apply a shared view controller's camera (yaw, tilt, zoom, pan). A change under half a pixel is not drawn, a settled change always is,
|
|
211
|
+
* and draws are at most 30 a second.
|
|
212
|
+
*/
|
|
213
|
+
setView(view, final = true) {
|
|
214
|
+
this.#model.view = { ...view };
|
|
215
|
+
const drawn = this.#model.drawn;
|
|
216
|
+
if (!drawn) {
|
|
217
|
+
this.#gate?.request();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const shifted = view.zoom !== drawn.view.zoom || view.panX !== drawn.view.panX || view.panY !== drawn.view.panY;
|
|
221
|
+
const a = lookAt(drawn.centre, drawn.view);
|
|
222
|
+
const b = lookAt(this.#model.centre, view);
|
|
223
|
+
const px = (Math.abs(b.lon - a.lon) * Math.cos(((a.lat + b.lat) / 2) * RAD) + Math.abs(b.lat - a.lat)) * RAD * drawn.radius;
|
|
224
|
+
if (shifted || px >= 0.5 || (final && px > 0.01))
|
|
225
|
+
this.#gate?.request();
|
|
226
|
+
}
|
|
227
|
+
/** The view as it is (for a page that wants to read it back). */
|
|
228
|
+
get view() {
|
|
229
|
+
return { ...this.#model.view };
|
|
230
|
+
}
|
|
231
|
+
/** While the time is "now", the Sun and the clouds are put right once a minute (`live-seconds` changes that): no frames between. */
|
|
232
|
+
#startTick() {
|
|
233
|
+
clearInterval(this.#tick);
|
|
234
|
+
this.#tick = undefined;
|
|
235
|
+
if (!this.isConnected || this.#mode !== "now")
|
|
236
|
+
return;
|
|
237
|
+
const seconds = Number(this.getAttribute("live-seconds"));
|
|
238
|
+
const ms = Number.isFinite(seconds) && seconds > 0 ? Math.max(MIN_TICK_MS, seconds * 1000) : LIVE_TICK_MS;
|
|
239
|
+
this.#tick = setInterval(this.#onTick, ms);
|
|
240
|
+
}
|
|
241
|
+
#onTick = () => {
|
|
242
|
+
this.#model.time = Date.now();
|
|
243
|
+
this.#gate?.request();
|
|
244
|
+
};
|
|
245
|
+
#take = () => {
|
|
246
|
+
if (this.#view && !this.#view.live)
|
|
247
|
+
void this.#view.focus();
|
|
248
|
+
};
|
|
249
|
+
#textureBase() {
|
|
250
|
+
const given = this.getAttribute("texture-base");
|
|
251
|
+
const base = given ?? new URL("../textures/", import.meta.url).href;
|
|
252
|
+
return base.endsWith("/") ? base : `${base}/`;
|
|
253
|
+
}
|
|
254
|
+
#textureCap() {
|
|
255
|
+
const given = Number(this.getAttribute("max-texture"));
|
|
256
|
+
const memory = navigator.deviceMemory;
|
|
257
|
+
const cap = Number.isFinite(given) && given > 0 ? Math.min(given, MAX_TEXTURE) : MAX_TEXTURE;
|
|
258
|
+
return memory !== undefined && memory <= 2 ? Math.min(cap, 2048) : cap;
|
|
259
|
+
}
|
|
260
|
+
#set(state) {
|
|
261
|
+
if (this.#state === state)
|
|
262
|
+
return;
|
|
263
|
+
this.#state = state;
|
|
264
|
+
this.dataset["state"] = state;
|
|
265
|
+
}
|
|
266
|
+
#fail(reason) {
|
|
267
|
+
this.#set("unavailable");
|
|
268
|
+
this.dispatchEvent(new CustomEvent("chikyu-unavailable", { detail: { reason } }));
|
|
269
|
+
}
|
|
270
|
+
#locate() {
|
|
271
|
+
if (readCentre(this.getAttribute("center")) || typeof navigator === "undefined" || !navigator.geolocation)
|
|
272
|
+
return;
|
|
273
|
+
navigator.geolocation.getCurrentPosition((position) => {
|
|
274
|
+
this.centre = { lon: position.coords.longitude, lat: position.coords.latitude };
|
|
275
|
+
}, () => undefined, { timeout: 8000, maximumAge: 600000 });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (typeof customElements !== "undefined" && !customElements.get("chikyu-earth-realistic")) {
|
|
279
|
+
customElements.define("chikyu-earth-realistic", ChikyuEarthRealistic);
|
|
280
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface Timers {
|
|
2
|
+
set(callback: () => void, ms: number): unknown;
|
|
3
|
+
clear(handle: unknown): void;
|
|
4
|
+
now(): number;
|
|
5
|
+
}
|
|
6
|
+
export declare const browserTimers: Timers;
|
|
7
|
+
export interface Gate {
|
|
8
|
+
/** Ask for `fire` to run: now if the last run was `minMs` ago or more, else once, when that time has passed. */
|
|
9
|
+
request(): void;
|
|
10
|
+
/** Forget a run that is waiting. */
|
|
11
|
+
cancel(): void;
|
|
12
|
+
/** Change the least time between runs (a lower frame rate on a battery). */
|
|
13
|
+
setMinMs(minMs: number): void;
|
|
14
|
+
}
|
|
15
|
+
export declare function createGate(minMs: number, fire: () => void, timers?: Timers): Gate;
|
|
16
|
+
/** The frame rate cap for this device: `maxFps` (30 by default), lower on a small device. */
|
|
17
|
+
export declare function frameCap(maxFps?: number, device?: {
|
|
18
|
+
deviceMemory?: number;
|
|
19
|
+
hardwareConcurrency?: number;
|
|
20
|
+
}): number;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// A rate gate for the draw: asks that come faster than `minMs` apart become one, and the last ask is never lost. Pure, with its own clock.
|
|
2
|
+
export const browserTimers = {
|
|
3
|
+
set: (callback, ms) => setTimeout(callback, ms),
|
|
4
|
+
clear: (handle) => clearTimeout(handle),
|
|
5
|
+
now: () => performance.now(),
|
|
6
|
+
};
|
|
7
|
+
export function createGate(minMs, fire, timers = browserTimers) {
|
|
8
|
+
let least = minMs;
|
|
9
|
+
let last = -Infinity;
|
|
10
|
+
let waiting;
|
|
11
|
+
const run = () => {
|
|
12
|
+
waiting = undefined;
|
|
13
|
+
last = timers.now();
|
|
14
|
+
fire();
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
request() {
|
|
18
|
+
if (waiting !== undefined)
|
|
19
|
+
return;
|
|
20
|
+
const wait = last + least - timers.now();
|
|
21
|
+
if (wait <= 0)
|
|
22
|
+
run();
|
|
23
|
+
else
|
|
24
|
+
waiting = timers.set(run, wait);
|
|
25
|
+
},
|
|
26
|
+
cancel() {
|
|
27
|
+
if (waiting !== undefined)
|
|
28
|
+
timers.clear(waiting);
|
|
29
|
+
waiting = undefined;
|
|
30
|
+
},
|
|
31
|
+
setMinMs(value) {
|
|
32
|
+
least = value;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** The frame rate cap for this device: `maxFps` (30 by default), lower on a small device. */
|
|
37
|
+
export function frameCap(maxFps = 30, device = {}) {
|
|
38
|
+
const small = (device.deviceMemory !== undefined && device.deviceMemory <= 2) || (device.hardwareConcurrency !== undefined && device.hardwareConcurrency <= 2);
|
|
39
|
+
return Math.max(1, small ? Math.min(maxFps, 20) : maxFps);
|
|
40
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { subsolarPoint, unitVector, julianDay } from "./sun.ts";
|
|
2
|
+
export { chooseTier, textureBytes, textureCap, MAX_TEXTURE, TIERS, TEXELS_PER_RADIUS_PIXEL, type Layer } from "./tiers.ts";
|
|
3
|
+
export { createGate, frameCap, type Gate } from "./gate.ts";
|
|
4
|
+
export { lookAt, discRadius, ATMOSPHERE_OUTER } from "./earth.ts";
|
|
5
|
+
export type { ThreeParts } from "./earth.ts";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The realistic Earth's pure parts, which import on a server and need neither three.js nor a browser. The element is
|
|
2
|
+
// `@johnmorrisdotca/chikyu-earth/realistic/element`.
|
|
3
|
+
export { subsolarPoint, unitVector, julianDay } from "./sun.js";
|
|
4
|
+
export { chooseTier, textureBytes, textureCap, MAX_TEXTURE, TIERS, TEXELS_PER_RADIUS_PIXEL } from "./tiers.js";
|
|
5
|
+
export { createGate, frameCap } from "./gate.js";
|
|
6
|
+
export { lookAt, discRadius, ATMOSPHERE_OUTER } from "./earth.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const SURFACE_VERT = "\nvarying vec2 vUv;\nvarying vec3 vNormal;\nvoid main() {\n vUv = uv;\n vNormal = normalize(mat3(modelMatrix) * position);\n gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n}";
|
|
2
|
+
export declare const SURFACE_FRAG = "\nuniform sampler2D dayMap;\nuniform sampler2D nightMap;\nuniform sampler2D waterMap;\nuniform vec3 sunDir;\nuniform float lights;\nvarying vec2 vUv;\nvarying vec3 vNormal;\nvoid main() {\n vec3 n = normalize(vNormal);\n float ndl = dot(n, sunDir);\n float day = smoothstep(-0.10, 0.22, ndl);\n vec3 albedo = texture2D(dayMap, vUv).rgb;\n float water = texture2D(waterMap, vUv).r;\n\n vec3 lit = albedo * (0.07 + 1.0 * pow(max(ndl, 0.0), 0.5));\n float dusk = smoothstep(-0.14, 0.0, ndl) * (1.0 - smoothstep(0.0, 0.30, ndl));\n lit += vec3(0.55, 0.28, 0.14) * dusk * 0.30 * (0.35 + 0.65 * albedo.g);\n\n vec3 h = normalize(sunDir + vec3(0.0, 0.0, 1.0));\n float glint = pow(max(dot(n, h), 0.0), 220.0) * water * day;\n lit += vec3(1.0, 0.93, 0.80) * glint * 0.7;\n\n vec3 night = texture2D(nightMap, vUv).rgb;\n night = night * night * vec3(1.6, 1.5, 1.3) * lights + night * 0.25;\n vec3 col = mix(night, lit, day);\n\n float rim = pow(1.0 - clamp(n.z, 0.0, 1.0), 3.0);\n float sunSide = 0.12 + 0.88 * smoothstep(-0.25, 0.5, ndl);\n col += vec3(0.20, 0.45, 1.0) * rim * sunSide * 0.55;\n gl_FragColor = vec4(col, 1.0);\n}";
|
|
3
|
+
export declare const CLOUD_FRAG = "\nuniform sampler2D cloudMap;\nuniform vec3 sunDir;\nuniform float shift;\nuniform float opacity;\nvarying vec2 vUv;\nvarying vec3 vNormal;\nvoid main() {\n vec3 n = normalize(vNormal);\n float ndl = dot(n, sunDir);\n float c = texture2D(cloudMap, vec2(vUv.x + shift, vUv.y)).r;\n float a = smoothstep(0.28, 0.88, c) * opacity;\n float day = smoothstep(-0.10, 0.25, ndl);\n vec3 col = mix(vec3(0.015, 0.02, 0.04), vec3(1.0), day * (0.18 + 0.82 * pow(max(ndl, 0.0), 0.45)));\n col += vec3(0.55, 0.28, 0.14) * smoothstep(-0.14, 0.0, ndl) * (1.0 - smoothstep(0.0, 0.25, ndl)) * 0.35;\n gl_FragColor = vec4(col, a);\n}";
|
|
4
|
+
export declare const ATMOSPHERE_VERT = "\nvarying vec3 vView;\nvoid main() {\n vec4 view = modelViewMatrix * vec4(position, 1.0);\n vView = view.xyz;\n gl_Position = projectionMatrix * view;\n}";
|
|
5
|
+
export declare const ATMOSPHERE_FRAG = "\nuniform vec3 sunDir;\nuniform float outer;\nvarying vec3 vView;\nvoid main() {\n float rho = length(vView.xy);\n float t = clamp((rho - 1.0) / (outer - 1.0), 0.0, 1.0);\n vec2 dir = rho > 0.0 ? vView.xy / rho : vec2(1.0, 0.0);\n float side = smoothstep(-0.40, 0.55, dot(vec3(dir, 0.0), sunDir));\n float glow = pow(1.0 - t, 3.0) * (0.10 + 0.90 * side);\n vec3 col = mix(vec3(0.10, 0.22, 0.60), vec3(0.42, 0.68, 1.0), side);\n gl_FragColor = vec4(col, glow * 0.9);\n}";
|