canvas-globe 0.1.0 → 0.1.2
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 +150 -137
- package/README.md +665 -638
- package/codemeta.json +1 -1
- package/dist/canvas-globe.umd.js +4280 -4265
- package/package.json +56 -56
- package/src/csv.js +151 -151
- package/src/element.js +142 -142
- package/src/export.js +19 -19
- package/src/geo-globe.js +2846 -2846
- package/src/geo.js +286 -286
- package/src/index.js +10 -10
- package/src/media.js +160 -160
- package/src/presets.js +21 -21
- package/src/react.js +60 -60
- package/src/recorder.js +70 -70
- package/src/scenes.js +80 -80
- package/src/texture.js +129 -129
- package/src/themes.js +241 -241
- package/src/viewer.js +130 -130
- package/types/element.d.ts +41 -41
- package/types/index.d.ts +674 -674
- package/types/react.d.ts +15 -15
package/src/media.js
CHANGED
|
@@ -1,160 +1,160 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Media sources that can be painted inside a country's outline: a still, an
|
|
3
|
-
* animated GIF, a video, another canvas, or a live MediaStream.
|
|
4
|
-
*
|
|
5
|
-
* Everything is drawn with `drawImage`, so the only real work is keeping a
|
|
6
|
-
* drawable element alive and knowing whether it needs a redraw each frame.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
const VIDEO_RE = /\.(mp4|webm|ogv|mov|m4v)(\?|#|$)/i;
|
|
10
|
-
const GIF_RE = /\.gif(\?|#|$)/i;
|
|
11
|
-
|
|
12
|
-
const isDrawable = (v) =>
|
|
13
|
-
!!v && typeof v === "object" &&
|
|
14
|
-
(v.nodeName === "IMG" || v.nodeName === "VIDEO" || v.nodeName === "CANVAS" ||
|
|
15
|
-
(typeof ImageBitmap !== "undefined" && v instanceof ImageBitmap) ||
|
|
16
|
-
(typeof OffscreenCanvas !== "undefined" && v instanceof OffscreenCanvas));
|
|
17
|
-
|
|
18
|
-
const isStream = (v) => typeof MediaStream !== "undefined" && v instanceof MediaStream;
|
|
19
|
-
|
|
20
|
-
/** Keeps GIFs advancing: browsers only animate images attached to the document. */
|
|
21
|
-
const parkOffscreen = (el) => {
|
|
22
|
-
if (typeof document === "undefined") return;
|
|
23
|
-
el.style.cssText = "position:fixed;right:0;bottom:0;width:1px;height:1px;opacity:.01;pointer-events:none;z-index:-1";
|
|
24
|
-
document.body.appendChild(el);
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export class Media {
|
|
28
|
-
constructor(spec, onReady) {
|
|
29
|
-
const config = spec && typeof spec === "object" && "src" in spec && !isDrawable(spec) ? spec : { src: spec };
|
|
30
|
-
this.fit = config.fit || "cover";
|
|
31
|
-
this.opacity = config.opacity ?? 1;
|
|
32
|
-
this.blend = config.blend || null;
|
|
33
|
-
this.scale = config.scale ?? 1;
|
|
34
|
-
this.offset = config.offset || [0, 0];
|
|
35
|
-
this.ready = false;
|
|
36
|
-
this.error = null;
|
|
37
|
-
this._owned = false;
|
|
38
|
-
this._onReady = onReady;
|
|
39
|
-
this._load(config);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
_load(config) {
|
|
43
|
-
const src = config.src;
|
|
44
|
-
if (isDrawable(src)) {
|
|
45
|
-
this.element = src;
|
|
46
|
-
this.ready = true;
|
|
47
|
-
this._kind = src.nodeName === "VIDEO" ? "video" : "static";
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
if (isStream(src)) {
|
|
51
|
-
this.element = this._video(config);
|
|
52
|
-
if (this.element) this.element.srcObject = src;
|
|
53
|
-
this._kind = "video";
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
if (typeof src !== "string") {
|
|
57
|
-
this.error = new Error("canvas-globe: unsupported media source");
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
if (config.type === "video" || (config.type !== "image" && VIDEO_RE.test(src))) {
|
|
61
|
-
this.element = this._video(config);
|
|
62
|
-
if (this.element) this.element.src = src;
|
|
63
|
-
this._kind = "video";
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
if (typeof Image === "undefined") return;
|
|
67
|
-
const img = new Image();
|
|
68
|
-
img.crossOrigin = config.crossOrigin ?? "anonymous";
|
|
69
|
-
img.onload = () => {
|
|
70
|
-
this.ready = true;
|
|
71
|
-
this._onReady?.(this);
|
|
72
|
-
};
|
|
73
|
-
img.onerror = () => {
|
|
74
|
-
this.error = new Error(`canvas-globe: could not load "${src}"`);
|
|
75
|
-
this._onReady?.(this);
|
|
76
|
-
};
|
|
77
|
-
img.src = src;
|
|
78
|
-
this.element = img;
|
|
79
|
-
this._owned = true;
|
|
80
|
-
this._kind = GIF_RE.test(src) ? "gif" : "static";
|
|
81
|
-
if (this._kind === "gif") parkOffscreen(img);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
_video(config) {
|
|
85
|
-
if (typeof document === "undefined") {
|
|
86
|
-
this.error = new Error("canvas-globe: video media needs a DOM");
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
|
-
const el = document.createElement("video");
|
|
90
|
-
el.muted = config.muted !== false;
|
|
91
|
-
el.loop = config.loop !== false;
|
|
92
|
-
el.autoplay = true;
|
|
93
|
-
el.playsInline = true;
|
|
94
|
-
el.crossOrigin = config.crossOrigin ?? "anonymous";
|
|
95
|
-
el.oncanplay = () => {
|
|
96
|
-
this.ready = true;
|
|
97
|
-
this._onReady?.(this);
|
|
98
|
-
};
|
|
99
|
-
el.onerror = () => {
|
|
100
|
-
this.error = new Error("canvas-globe: video could not be played");
|
|
101
|
-
this._onReady?.(this);
|
|
102
|
-
};
|
|
103
|
-
this._owned = true;
|
|
104
|
-
parkOffscreen(el);
|
|
105
|
-
el.play?.().catch(() => {});
|
|
106
|
-
return el;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Natural pixel size of the current frame. */
|
|
110
|
-
size() {
|
|
111
|
-
const el = this.element;
|
|
112
|
-
if (!el) return null;
|
|
113
|
-
const w = el.videoWidth || el.naturalWidth || el.width;
|
|
114
|
-
const h = el.videoHeight || el.naturalHeight || el.height;
|
|
115
|
-
return w && h ? [w, h] : null;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** True when the source changes on its own and needs a redraw every frame. */
|
|
119
|
-
get animated() {
|
|
120
|
-
if (!this.element) return false;
|
|
121
|
-
if (this._kind === "gif") return true;
|
|
122
|
-
return this._kind === "video" && !this.element.paused && !this.element.ended;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
destroy() {
|
|
126
|
-
if (!this._owned || !this.element) return;
|
|
127
|
-
if (this._kind === "video") {
|
|
128
|
-
this.element.pause?.();
|
|
129
|
-
this.element.srcObject = null;
|
|
130
|
-
this.element.removeAttribute("src");
|
|
131
|
-
}
|
|
132
|
-
this.element.remove?.();
|
|
133
|
-
this.element = null;
|
|
134
|
-
this.ready = false;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Draws media into a screen rect the way CSS `object-fit` would.
|
|
140
|
-
* The caller is expected to have clipped to the target shape already.
|
|
141
|
-
*/
|
|
142
|
-
export function drawFitted(ctx, media, box) {
|
|
143
|
-
const size = media.size();
|
|
144
|
-
if (!size) return false;
|
|
145
|
-
const [mw, mh] = size;
|
|
146
|
-
const [x, y, w, h] = box;
|
|
147
|
-
if (w <= 0 || h <= 0) return false;
|
|
148
|
-
let scale;
|
|
149
|
-
if (media.fit === "contain") scale = Math.min(w / mw, h / mh);
|
|
150
|
-
else if (media.fit === "fill") scale = null;
|
|
151
|
-
else scale = Math.max(w / mw, h / mh);
|
|
152
|
-
const k = media.scale || 1;
|
|
153
|
-
if (scale === null) {
|
|
154
|
-
ctx.drawImage(media.element, x, y, w, h);
|
|
155
|
-
return true;
|
|
156
|
-
}
|
|
157
|
-
const dw = mw * scale * k, dh = mh * scale * k;
|
|
158
|
-
ctx.drawImage(media.element, x + (w - dw) / 2 + media.offset[0], y + (h - dh) / 2 + media.offset[1], dw, dh);
|
|
159
|
-
return true;
|
|
160
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Media sources that can be painted inside a country's outline: a still, an
|
|
3
|
+
* animated GIF, a video, another canvas, or a live MediaStream.
|
|
4
|
+
*
|
|
5
|
+
* Everything is drawn with `drawImage`, so the only real work is keeping a
|
|
6
|
+
* drawable element alive and knowing whether it needs a redraw each frame.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const VIDEO_RE = /\.(mp4|webm|ogv|mov|m4v)(\?|#|$)/i;
|
|
10
|
+
const GIF_RE = /\.gif(\?|#|$)/i;
|
|
11
|
+
|
|
12
|
+
const isDrawable = (v) =>
|
|
13
|
+
!!v && typeof v === "object" &&
|
|
14
|
+
(v.nodeName === "IMG" || v.nodeName === "VIDEO" || v.nodeName === "CANVAS" ||
|
|
15
|
+
(typeof ImageBitmap !== "undefined" && v instanceof ImageBitmap) ||
|
|
16
|
+
(typeof OffscreenCanvas !== "undefined" && v instanceof OffscreenCanvas));
|
|
17
|
+
|
|
18
|
+
const isStream = (v) => typeof MediaStream !== "undefined" && v instanceof MediaStream;
|
|
19
|
+
|
|
20
|
+
/** Keeps GIFs advancing: browsers only animate images attached to the document. */
|
|
21
|
+
const parkOffscreen = (el) => {
|
|
22
|
+
if (typeof document === "undefined") return;
|
|
23
|
+
el.style.cssText = "position:fixed;right:0;bottom:0;width:1px;height:1px;opacity:.01;pointer-events:none;z-index:-1";
|
|
24
|
+
document.body.appendChild(el);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export class Media {
|
|
28
|
+
constructor(spec, onReady) {
|
|
29
|
+
const config = spec && typeof spec === "object" && "src" in spec && !isDrawable(spec) ? spec : { src: spec };
|
|
30
|
+
this.fit = config.fit || "cover";
|
|
31
|
+
this.opacity = config.opacity ?? 1;
|
|
32
|
+
this.blend = config.blend || null;
|
|
33
|
+
this.scale = config.scale ?? 1;
|
|
34
|
+
this.offset = config.offset || [0, 0];
|
|
35
|
+
this.ready = false;
|
|
36
|
+
this.error = null;
|
|
37
|
+
this._owned = false;
|
|
38
|
+
this._onReady = onReady;
|
|
39
|
+
this._load(config);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_load(config) {
|
|
43
|
+
const src = config.src;
|
|
44
|
+
if (isDrawable(src)) {
|
|
45
|
+
this.element = src;
|
|
46
|
+
this.ready = true;
|
|
47
|
+
this._kind = src.nodeName === "VIDEO" ? "video" : "static";
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (isStream(src)) {
|
|
51
|
+
this.element = this._video(config);
|
|
52
|
+
if (this.element) this.element.srcObject = src;
|
|
53
|
+
this._kind = "video";
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (typeof src !== "string") {
|
|
57
|
+
this.error = new Error("canvas-globe: unsupported media source");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (config.type === "video" || (config.type !== "image" && VIDEO_RE.test(src))) {
|
|
61
|
+
this.element = this._video(config);
|
|
62
|
+
if (this.element) this.element.src = src;
|
|
63
|
+
this._kind = "video";
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (typeof Image === "undefined") return;
|
|
67
|
+
const img = new Image();
|
|
68
|
+
img.crossOrigin = config.crossOrigin ?? "anonymous";
|
|
69
|
+
img.onload = () => {
|
|
70
|
+
this.ready = true;
|
|
71
|
+
this._onReady?.(this);
|
|
72
|
+
};
|
|
73
|
+
img.onerror = () => {
|
|
74
|
+
this.error = new Error(`canvas-globe: could not load "${src}"`);
|
|
75
|
+
this._onReady?.(this);
|
|
76
|
+
};
|
|
77
|
+
img.src = src;
|
|
78
|
+
this.element = img;
|
|
79
|
+
this._owned = true;
|
|
80
|
+
this._kind = GIF_RE.test(src) ? "gif" : "static";
|
|
81
|
+
if (this._kind === "gif") parkOffscreen(img);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_video(config) {
|
|
85
|
+
if (typeof document === "undefined") {
|
|
86
|
+
this.error = new Error("canvas-globe: video media needs a DOM");
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const el = document.createElement("video");
|
|
90
|
+
el.muted = config.muted !== false;
|
|
91
|
+
el.loop = config.loop !== false;
|
|
92
|
+
el.autoplay = true;
|
|
93
|
+
el.playsInline = true;
|
|
94
|
+
el.crossOrigin = config.crossOrigin ?? "anonymous";
|
|
95
|
+
el.oncanplay = () => {
|
|
96
|
+
this.ready = true;
|
|
97
|
+
this._onReady?.(this);
|
|
98
|
+
};
|
|
99
|
+
el.onerror = () => {
|
|
100
|
+
this.error = new Error("canvas-globe: video could not be played");
|
|
101
|
+
this._onReady?.(this);
|
|
102
|
+
};
|
|
103
|
+
this._owned = true;
|
|
104
|
+
parkOffscreen(el);
|
|
105
|
+
el.play?.().catch(() => {});
|
|
106
|
+
return el;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Natural pixel size of the current frame. */
|
|
110
|
+
size() {
|
|
111
|
+
const el = this.element;
|
|
112
|
+
if (!el) return null;
|
|
113
|
+
const w = el.videoWidth || el.naturalWidth || el.width;
|
|
114
|
+
const h = el.videoHeight || el.naturalHeight || el.height;
|
|
115
|
+
return w && h ? [w, h] : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** True when the source changes on its own and needs a redraw every frame. */
|
|
119
|
+
get animated() {
|
|
120
|
+
if (!this.element) return false;
|
|
121
|
+
if (this._kind === "gif") return true;
|
|
122
|
+
return this._kind === "video" && !this.element.paused && !this.element.ended;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
destroy() {
|
|
126
|
+
if (!this._owned || !this.element) return;
|
|
127
|
+
if (this._kind === "video") {
|
|
128
|
+
this.element.pause?.();
|
|
129
|
+
this.element.srcObject = null;
|
|
130
|
+
this.element.removeAttribute("src");
|
|
131
|
+
}
|
|
132
|
+
this.element.remove?.();
|
|
133
|
+
this.element = null;
|
|
134
|
+
this.ready = false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Draws media into a screen rect the way CSS `object-fit` would.
|
|
140
|
+
* The caller is expected to have clipped to the target shape already.
|
|
141
|
+
*/
|
|
142
|
+
export function drawFitted(ctx, media, box) {
|
|
143
|
+
const size = media.size();
|
|
144
|
+
if (!size) return false;
|
|
145
|
+
const [mw, mh] = size;
|
|
146
|
+
const [x, y, w, h] = box;
|
|
147
|
+
if (w <= 0 || h <= 0) return false;
|
|
148
|
+
let scale;
|
|
149
|
+
if (media.fit === "contain") scale = Math.min(w / mw, h / mh);
|
|
150
|
+
else if (media.fit === "fill") scale = null;
|
|
151
|
+
else scale = Math.max(w / mw, h / mh);
|
|
152
|
+
const k = media.scale || 1;
|
|
153
|
+
if (scale === null) {
|
|
154
|
+
ctx.drawImage(media.element, x, y, w, h);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
const dw = mw * scale * k, dh = mh * scale * k;
|
|
158
|
+
ctx.drawImage(media.element, x + (w - dw) / 2 + media.offset[0], y + (h - dh) / 2 + media.offset[1], dw, dh);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
package/src/presets.js
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Named bundles of theme + render style. `createGlobe(canvas, { preset })`
|
|
3
|
-
* applies one, and your own options still win over anything it sets.
|
|
4
|
-
*/
|
|
5
|
-
export const presets = {
|
|
6
|
-
atlas: { theme: "atlas", landStyle: "fill", graticule: true, stars: true, shade: true, orbits: 0 },
|
|
7
|
-
midnight: { theme: "midnight", landStyle: "fill", graticule: true, stars: true, shade: true, orbits: 0 },
|
|
8
|
-
mono: { theme: "mono", landStyle: "fill", graticule: true, stars: false, shade: false, orbits: 0 },
|
|
9
|
-
political: { theme: "political", landStyle: "fill", countryColors: "auto", graticule: true, stars: false, shade: true, orbits: 0 },
|
|
10
|
-
hologram: { theme: "hologram", landStyle: "dots", dotSpacing: 2, dotSize: 1.15, graticule: true, stars: true, shade: false, orbits: 0 },
|
|
11
|
-
neon: { theme: "neon", landStyle: "glow", graticule: true, stars: true, shade: false, orbits: 0 },
|
|
12
|
-
blueprint: { theme: "blueprint", landStyle: "outline", graticule: true, stars: false, shade: false, orbits: 3 },
|
|
13
|
-
aurora: { theme: "aurora", landStyle: "dots", dotSpacing: 2.6, dotSize: 1.4, graticule: false, stars: true, shade: false, orbits: 2 },
|
|
14
|
-
noir: { theme: "noir", landStyle: "outline", graticule: false, stars: false, shade: false, orbits: 0 },
|
|
15
|
-
constellation: { theme: "hologram", landStyle: "dots", dotSpacing: 3, dotSize: 1.6, graticule: true, stars: true, shade: false, orbits: 3 },
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
/** Option keys a preset owns; anything unset falls back to the default. */
|
|
19
|
-
export const presetKeys = [
|
|
20
|
-
"theme", "landStyle", "dotSpacing", "dotSize", "graticule", "stars", "shade", "orbits", "countryColors",
|
|
21
|
-
];
|
|
1
|
+
/**
|
|
2
|
+
* Named bundles of theme + render style. `createGlobe(canvas, { preset })`
|
|
3
|
+
* applies one, and your own options still win over anything it sets.
|
|
4
|
+
*/
|
|
5
|
+
export const presets = {
|
|
6
|
+
atlas: { theme: "atlas", landStyle: "fill", graticule: true, stars: true, shade: true, orbits: 0 },
|
|
7
|
+
midnight: { theme: "midnight", landStyle: "fill", graticule: true, stars: true, shade: true, orbits: 0 },
|
|
8
|
+
mono: { theme: "mono", landStyle: "fill", graticule: true, stars: false, shade: false, orbits: 0 },
|
|
9
|
+
political: { theme: "political", landStyle: "fill", countryColors: "auto", graticule: true, stars: false, shade: true, orbits: 0 },
|
|
10
|
+
hologram: { theme: "hologram", landStyle: "dots", dotSpacing: 2, dotSize: 1.15, graticule: true, stars: true, shade: false, orbits: 0 },
|
|
11
|
+
neon: { theme: "neon", landStyle: "glow", graticule: true, stars: true, shade: false, orbits: 0 },
|
|
12
|
+
blueprint: { theme: "blueprint", landStyle: "outline", graticule: true, stars: false, shade: false, orbits: 3 },
|
|
13
|
+
aurora: { theme: "aurora", landStyle: "dots", dotSpacing: 2.6, dotSize: 1.4, graticule: false, stars: true, shade: false, orbits: 2 },
|
|
14
|
+
noir: { theme: "noir", landStyle: "outline", graticule: false, stars: false, shade: false, orbits: 0 },
|
|
15
|
+
constellation: { theme: "hologram", landStyle: "dots", dotSpacing: 3, dotSize: 1.6, graticule: true, stars: true, shade: false, orbits: 3 },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Option keys a preset owns; anything unset falls back to the default. */
|
|
19
|
+
export const presetKeys = [
|
|
20
|
+
"theme", "landStyle", "dotSpacing", "dotSize", "graticule", "stars", "shade", "orbits", "countryColors",
|
|
21
|
+
];
|
package/src/react.js
CHANGED
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* React binding. `import { Globe } from "canvas-globe/react"`.
|
|
3
|
-
* React is a peer dependency and is only required by this entry point.
|
|
4
|
-
*/
|
|
5
|
-
import { createElement, forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
|
6
|
-
import { GeoGlobe } from "./geo-globe.js";
|
|
7
|
-
import { mapAspect } from "./geo.js";
|
|
8
|
-
|
|
9
|
-
const CALLBACKS = ["onHover", "onClick", "onCountryHover", "onCountryClick", "onRender"];
|
|
10
|
-
|
|
11
|
-
export const Globe = forwardRef(function Globe(props, ref) {
|
|
12
|
-
const { className, style, ...rest } = props;
|
|
13
|
-
const options = {};
|
|
14
|
-
for (const key of Object.keys(rest)) if (!CALLBACKS.includes(key)) options[key] = rest[key];
|
|
15
|
-
|
|
16
|
-
const canvasRef = useRef(null);
|
|
17
|
-
const globeRef = useRef(null);
|
|
18
|
-
const handlers = useRef(props);
|
|
19
|
-
const previous = useRef(null);
|
|
20
|
-
const initial = useRef(options);
|
|
21
|
-
handlers.current = props;
|
|
22
|
-
|
|
23
|
-
useEffect(() => {
|
|
24
|
-
const bound = {};
|
|
25
|
-
for (const name of CALLBACKS) bound[name] = (...args) => handlers.current[name]?.(...args);
|
|
26
|
-
const globe = new GeoGlobe(canvasRef.current, { ...initial.current, ...bound });
|
|
27
|
-
globeRef.current = globe;
|
|
28
|
-
previous.current = initial.current;
|
|
29
|
-
return () => {
|
|
30
|
-
globe.destroy();
|
|
31
|
-
globeRef.current = null;
|
|
32
|
-
};
|
|
33
|
-
}, []);
|
|
34
|
-
|
|
35
|
-
// Shallow diff every render so callers can pass inline objects freely.
|
|
36
|
-
useEffect(() => {
|
|
37
|
-
const globe = globeRef.current;
|
|
38
|
-
if (!globe) return;
|
|
39
|
-
const patch = {};
|
|
40
|
-
let changed = false;
|
|
41
|
-
for (const key of Object.keys(options)) {
|
|
42
|
-
if (previous.current && previous.current[key] === options[key]) continue;
|
|
43
|
-
patch[key] = options[key];
|
|
44
|
-
changed = true;
|
|
45
|
-
}
|
|
46
|
-
previous.current = options;
|
|
47
|
-
if (changed) globe.setOptions(patch);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
useImperativeHandle(ref, () => globeRef.current);
|
|
51
|
-
|
|
52
|
-
const aspect = options.mode === "map" ? 1 / mapAspect(options.latRange, options.projection) : 1;
|
|
53
|
-
return createElement("canvas", {
|
|
54
|
-
ref: canvasRef,
|
|
55
|
-
className,
|
|
56
|
-
style: { width: "100%", aspectRatio: String(aspect), ...style },
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
export default Globe;
|
|
1
|
+
/**
|
|
2
|
+
* React binding. `import { Globe } from "canvas-globe/react"`.
|
|
3
|
+
* React is a peer dependency and is only required by this entry point.
|
|
4
|
+
*/
|
|
5
|
+
import { createElement, forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
|
6
|
+
import { GeoGlobe } from "./geo-globe.js";
|
|
7
|
+
import { mapAspect } from "./geo.js";
|
|
8
|
+
|
|
9
|
+
const CALLBACKS = ["onHover", "onClick", "onCountryHover", "onCountryClick", "onRender"];
|
|
10
|
+
|
|
11
|
+
export const Globe = forwardRef(function Globe(props, ref) {
|
|
12
|
+
const { className, style, ...rest } = props;
|
|
13
|
+
const options = {};
|
|
14
|
+
for (const key of Object.keys(rest)) if (!CALLBACKS.includes(key)) options[key] = rest[key];
|
|
15
|
+
|
|
16
|
+
const canvasRef = useRef(null);
|
|
17
|
+
const globeRef = useRef(null);
|
|
18
|
+
const handlers = useRef(props);
|
|
19
|
+
const previous = useRef(null);
|
|
20
|
+
const initial = useRef(options);
|
|
21
|
+
handlers.current = props;
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const bound = {};
|
|
25
|
+
for (const name of CALLBACKS) bound[name] = (...args) => handlers.current[name]?.(...args);
|
|
26
|
+
const globe = new GeoGlobe(canvasRef.current, { ...initial.current, ...bound });
|
|
27
|
+
globeRef.current = globe;
|
|
28
|
+
previous.current = initial.current;
|
|
29
|
+
return () => {
|
|
30
|
+
globe.destroy();
|
|
31
|
+
globeRef.current = null;
|
|
32
|
+
};
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
// Shallow diff every render so callers can pass inline objects freely.
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const globe = globeRef.current;
|
|
38
|
+
if (!globe) return;
|
|
39
|
+
const patch = {};
|
|
40
|
+
let changed = false;
|
|
41
|
+
for (const key of Object.keys(options)) {
|
|
42
|
+
if (previous.current && previous.current[key] === options[key]) continue;
|
|
43
|
+
patch[key] = options[key];
|
|
44
|
+
changed = true;
|
|
45
|
+
}
|
|
46
|
+
previous.current = options;
|
|
47
|
+
if (changed) globe.setOptions(patch);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
useImperativeHandle(ref, () => globeRef.current);
|
|
51
|
+
|
|
52
|
+
const aspect = options.mode === "map" ? 1 / mapAspect(options.latRange, options.projection) : 1;
|
|
53
|
+
return createElement("canvas", {
|
|
54
|
+
ref: canvasRef,
|
|
55
|
+
className,
|
|
56
|
+
style: { width: "100%", aspectRatio: String(aspect), ...style },
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export default Globe;
|
package/src/recorder.js
CHANGED
|
@@ -1,70 +1,70 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Canvas → WebM recording via MediaRecorder. No encoder dependency, no upload,
|
|
3
|
-
* no server: the clip is produced entirely in the tab.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
const CANDIDATES = [
|
|
7
|
-
"video/webm;codecs=vp9",
|
|
8
|
-
"video/webm;codecs=vp8",
|
|
9
|
-
"video/webm",
|
|
10
|
-
"video/mp4",
|
|
11
|
-
];
|
|
12
|
-
|
|
13
|
-
/** First container the browser can actually encode, or null. */
|
|
14
|
-
export function supportedRecordingType() {
|
|
15
|
-
if (typeof MediaRecorder === "undefined") return null;
|
|
16
|
-
for (const type of CANDIDATES) if (MediaRecorder.isTypeSupported?.(type)) return type;
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export const canRecord = () => supportedRecordingType() !== null;
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Records the canvas and resolves to a Blob. Pass `duration` for a fixed-length
|
|
24
|
-
* clip, or call `stop()` on the returned handle.
|
|
25
|
-
*/
|
|
26
|
-
export function recordCanvas(canvas, { duration = 6000, fps = 30, bitrate = 6e6, type } = {}) {
|
|
27
|
-
const mimeType = type || supportedRecordingType();
|
|
28
|
-
if (!mimeType) return { promise: Promise.reject(new Error("canvas-globe: MediaRecorder is unavailable")), stop() {} };
|
|
29
|
-
if (!canvas.captureStream) {
|
|
30
|
-
return { promise: Promise.reject(new Error("canvas-globe: canvas.captureStream is unavailable")), stop() {} };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const stream = canvas.captureStream(fps);
|
|
34
|
-
const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: bitrate });
|
|
35
|
-
const chunks = [];
|
|
36
|
-
let timer = null;
|
|
37
|
-
|
|
38
|
-
const promise = new Promise((resolve, reject) => {
|
|
39
|
-
recorder.ondataavailable = (e) => {
|
|
40
|
-
if (e.data && e.data.size) chunks.push(e.data);
|
|
41
|
-
};
|
|
42
|
-
recorder.onerror = (e) => reject(e.error || new Error("canvas-globe: recording failed"));
|
|
43
|
-
recorder.onstop = () => {
|
|
44
|
-
clearTimeout(timer);
|
|
45
|
-
for (const track of stream.getTracks()) track.stop();
|
|
46
|
-
resolve(new Blob(chunks, { type: mimeType }));
|
|
47
|
-
};
|
|
48
|
-
recorder.start();
|
|
49
|
-
if (duration > 0) timer = setTimeout(() => recorder.state !== "inactive" && recorder.stop(), duration);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
return {
|
|
53
|
-
promise,
|
|
54
|
-
mimeType,
|
|
55
|
-
stop() {
|
|
56
|
-
if (recorder.state !== "inactive") recorder.stop();
|
|
57
|
-
return promise;
|
|
58
|
-
},
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Triggers a browser download for a Blob. */
|
|
63
|
-
export function downloadBlob(blob, filename) {
|
|
64
|
-
const url = URL.createObjectURL(blob);
|
|
65
|
-
const a = document.createElement("a");
|
|
66
|
-
a.href = url;
|
|
67
|
-
a.download = filename;
|
|
68
|
-
a.click();
|
|
69
|
-
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
70
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Canvas → WebM recording via MediaRecorder. No encoder dependency, no upload,
|
|
3
|
+
* no server: the clip is produced entirely in the tab.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const CANDIDATES = [
|
|
7
|
+
"video/webm;codecs=vp9",
|
|
8
|
+
"video/webm;codecs=vp8",
|
|
9
|
+
"video/webm",
|
|
10
|
+
"video/mp4",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/** First container the browser can actually encode, or null. */
|
|
14
|
+
export function supportedRecordingType() {
|
|
15
|
+
if (typeof MediaRecorder === "undefined") return null;
|
|
16
|
+
for (const type of CANDIDATES) if (MediaRecorder.isTypeSupported?.(type)) return type;
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const canRecord = () => supportedRecordingType() !== null;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Records the canvas and resolves to a Blob. Pass `duration` for a fixed-length
|
|
24
|
+
* clip, or call `stop()` on the returned handle.
|
|
25
|
+
*/
|
|
26
|
+
export function recordCanvas(canvas, { duration = 6000, fps = 30, bitrate = 6e6, type } = {}) {
|
|
27
|
+
const mimeType = type || supportedRecordingType();
|
|
28
|
+
if (!mimeType) return { promise: Promise.reject(new Error("canvas-globe: MediaRecorder is unavailable")), stop() {} };
|
|
29
|
+
if (!canvas.captureStream) {
|
|
30
|
+
return { promise: Promise.reject(new Error("canvas-globe: canvas.captureStream is unavailable")), stop() {} };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const stream = canvas.captureStream(fps);
|
|
34
|
+
const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: bitrate });
|
|
35
|
+
const chunks = [];
|
|
36
|
+
let timer = null;
|
|
37
|
+
|
|
38
|
+
const promise = new Promise((resolve, reject) => {
|
|
39
|
+
recorder.ondataavailable = (e) => {
|
|
40
|
+
if (e.data && e.data.size) chunks.push(e.data);
|
|
41
|
+
};
|
|
42
|
+
recorder.onerror = (e) => reject(e.error || new Error("canvas-globe: recording failed"));
|
|
43
|
+
recorder.onstop = () => {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
for (const track of stream.getTracks()) track.stop();
|
|
46
|
+
resolve(new Blob(chunks, { type: mimeType }));
|
|
47
|
+
};
|
|
48
|
+
recorder.start();
|
|
49
|
+
if (duration > 0) timer = setTimeout(() => recorder.state !== "inactive" && recorder.stop(), duration);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
promise,
|
|
54
|
+
mimeType,
|
|
55
|
+
stop() {
|
|
56
|
+
if (recorder.state !== "inactive") recorder.stop();
|
|
57
|
+
return promise;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Triggers a browser download for a Blob. */
|
|
63
|
+
export function downloadBlob(blob, filename) {
|
|
64
|
+
const url = URL.createObjectURL(blob);
|
|
65
|
+
const a = document.createElement("a");
|
|
66
|
+
a.href = url;
|
|
67
|
+
a.download = filename;
|
|
68
|
+
a.click();
|
|
69
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
70
|
+
}
|