@asterisk-labs/rumi 0.0.0-stage → 0.27.0-alpha.1
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/LICENSE +232 -0
- package/README.md +73 -2
- package/index.d.ts +143 -0
- package/index.js +1 -0
- package/package.json +47 -4
- package/playground/README.md +26 -0
- package/playground/app.js +374 -0
- package/playground/asterisk-mark.svg +22 -0
- package/playground/index.html +101 -0
- package/playground/render.js +65 -0
- package/playground/rumi-mark.svg +15 -0
- package/playground/server.mjs +80 -0
- package/playground/styles.css +145 -0
- package/src/dtypes.js +48 -0
- package/src/epsg.js +138 -0
- package/src/file_header.js +284 -0
- package/src/header.js +334 -0
- package/src/reader.js +360 -0
- package/src/source.js +149 -0
- package/src/trailer.js +182 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { openRumi } from "/rumi/index.js";
|
|
2
|
+
import * as maplibregl from "https://cdn.jsdelivr.net/npm/maplibre-gl@6.9.0/dist/maplibre-gl.mjs";
|
|
3
|
+
import { affineCorners, rasterRgba } from "./render.js";
|
|
4
|
+
|
|
5
|
+
const SOURCE = "rumi-image";
|
|
6
|
+
const LAYER = "rumi-image";
|
|
7
|
+
const PROJ4_URL = "https://cdn.jsdelivr.net/npm/proj4@2.15.0/+esm";
|
|
8
|
+
const DATE_LIMIT_MS = 8_640_000_000_000_000n;
|
|
9
|
+
const MAX_PREVIEW_PIXELS = 2048 * 2048;
|
|
10
|
+
|
|
11
|
+
const element = Object.fromEntries([
|
|
12
|
+
"url-form", "rumi-url", "file-input", "status", "message", "controls",
|
|
13
|
+
"source-name", "source-info", "time-field", "time", "band-fields", "red",
|
|
14
|
+
"green", "blue", "opacity", "opacity-value", "render", "close", "busy",
|
|
15
|
+
"drop-zone", "metadata-panel", "metadata-title", "metadata-body",
|
|
16
|
+
"show-metadata",
|
|
17
|
+
].map((id) => [id.replaceAll("-", ""), document.getElementById(id)]));
|
|
18
|
+
|
|
19
|
+
const map = new maplibregl.Map({
|
|
20
|
+
container: "map",
|
|
21
|
+
center: [-76.9, -12.05],
|
|
22
|
+
zoom: 8,
|
|
23
|
+
attributionControl: false,
|
|
24
|
+
style: {
|
|
25
|
+
version: 8,
|
|
26
|
+
sources: {
|
|
27
|
+
basemap: {
|
|
28
|
+
type: "raster",
|
|
29
|
+
tiles: ["https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}"],
|
|
30
|
+
tileSize: 256,
|
|
31
|
+
maxzoom: 16,
|
|
32
|
+
},
|
|
33
|
+
labels: {
|
|
34
|
+
type: "raster",
|
|
35
|
+
tiles: ["https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Reference/MapServer/tile/{z}/{y}/{x}"],
|
|
36
|
+
tileSize: 256,
|
|
37
|
+
maxzoom: 16,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
layers: [
|
|
41
|
+
{ id: "basemap", type: "raster", source: "basemap" },
|
|
42
|
+
{ id: "labels", type: "raster", source: "labels" },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
map.addControl(new maplibregl.NavigationControl(), "bottom-right");
|
|
47
|
+
const mapReady = new Promise((resolve) => map.on("load", resolve));
|
|
48
|
+
let dataset;
|
|
49
|
+
|
|
50
|
+
function setStatus(text, state = "ready") {
|
|
51
|
+
element.status.className = `status ${state}`;
|
|
52
|
+
element.status.querySelector("span").textContent = text;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function showError(text) {
|
|
56
|
+
element.message.textContent = text;
|
|
57
|
+
element.message.className = "message error";
|
|
58
|
+
element.message.hidden = false;
|
|
59
|
+
setStatus("Error", "error");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function clearMessage() {
|
|
63
|
+
element.message.hidden = true;
|
|
64
|
+
element.message.textContent = "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function busy(active) {
|
|
68
|
+
element.busy.hidden = !active;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function formatInstant(seconds) {
|
|
72
|
+
const milliseconds = seconds * 1000n;
|
|
73
|
+
if (milliseconds >= -DATE_LIMIT_MS && milliseconds <= DATE_LIMIT_MS) {
|
|
74
|
+
return new Date(Number(milliseconds)).toISOString().replace(".000Z", "Z");
|
|
75
|
+
}
|
|
76
|
+
return `${seconds} seconds since 1970-01-01T00:00:00Z`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formatTime(value) {
|
|
80
|
+
return Array.isArray(value)
|
|
81
|
+
? `${formatInstant(value[0])} — ${formatInstant(value[1])}`
|
|
82
|
+
: formatInstant(value);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function optionText(label, index) {
|
|
86
|
+
return label ? `${index} · ${label}` : `Band ${index}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function bandOptions(select, labels, count, selected) {
|
|
90
|
+
select.replaceChildren(...Array.from({ length: count }, (_, band) => {
|
|
91
|
+
const option = document.createElement("option");
|
|
92
|
+
option.value = band;
|
|
93
|
+
option.textContent = optionText(labels?.[band], band);
|
|
94
|
+
option.title = labels?.[band] ?? `Band ${band}`;
|
|
95
|
+
option.selected = band === selected;
|
|
96
|
+
return option;
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function namedBand(labels, expression, fallback, count) {
|
|
101
|
+
const found = labels?.findIndex((label) => expression.test(label)) ?? -1;
|
|
102
|
+
return found >= 0 ? found : Math.min(fallback, count - 1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function metadataRow(name, value, code = false) {
|
|
106
|
+
const row = document.createElement("div");
|
|
107
|
+
row.className = "metadata-row";
|
|
108
|
+
const term = document.createElement("dt");
|
|
109
|
+
term.textContent = name;
|
|
110
|
+
const description = document.createElement("dd");
|
|
111
|
+
if (code) {
|
|
112
|
+
const block = document.createElement("span");
|
|
113
|
+
block.className = "code-value";
|
|
114
|
+
block.textContent = value;
|
|
115
|
+
description.append(block);
|
|
116
|
+
} else {
|
|
117
|
+
description.textContent = value;
|
|
118
|
+
}
|
|
119
|
+
row.append(term, description);
|
|
120
|
+
return row;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function metadataGroup(title, rows) {
|
|
124
|
+
const section = document.createElement("section");
|
|
125
|
+
section.className = "metadata-group";
|
|
126
|
+
const heading = document.createElement("h3");
|
|
127
|
+
heading.textContent = title;
|
|
128
|
+
const list = document.createElement("dl");
|
|
129
|
+
for (const row of rows) list.append(metadataRow(...row));
|
|
130
|
+
section.append(heading, list);
|
|
131
|
+
return section;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function bandGroup(labels, count) {
|
|
135
|
+
const section = document.createElement("section");
|
|
136
|
+
section.className = "metadata-group";
|
|
137
|
+
const heading = document.createElement("h3");
|
|
138
|
+
heading.textContent = `Bands · ${count}`;
|
|
139
|
+
const list = document.createElement("ol");
|
|
140
|
+
list.className = "band-list";
|
|
141
|
+
for (let band = 0; band < count; band += 1) {
|
|
142
|
+
const item = document.createElement("li");
|
|
143
|
+
const index = document.createElement("b");
|
|
144
|
+
index.textContent = String(band).padStart(2, "0");
|
|
145
|
+
const text = document.createElement("span");
|
|
146
|
+
text.textContent = labels?.[band] ?? `Band ${band}`;
|
|
147
|
+
item.append(index, text);
|
|
148
|
+
list.append(item);
|
|
149
|
+
}
|
|
150
|
+
section.append(heading, list);
|
|
151
|
+
return section;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function showMetadata(info, name) {
|
|
155
|
+
const bandCount = info.shape.length === 3 ? info.shape[0] : info.shape[1];
|
|
156
|
+
const timeCount = info.shape.length === 4 ? info.shape[0] : 1;
|
|
157
|
+
element.metadatatitle.textContent = name;
|
|
158
|
+
element.metadatabody.replaceChildren(
|
|
159
|
+
metadataGroup("Array", [
|
|
160
|
+
["Shape", info.shape.join(" × ")],
|
|
161
|
+
["Axes", info.axes.join(" · ")],
|
|
162
|
+
["Data type", `${info.dtype.name} · ${info.dtype.bits} bit`],
|
|
163
|
+
["Tile", info.tileShape.join(" × ")],
|
|
164
|
+
["Tile grid", info.gridShape.join(" × ")],
|
|
165
|
+
["Frame layout", info.frameLayout],
|
|
166
|
+
["Frames", String(info.frameCount)],
|
|
167
|
+
]),
|
|
168
|
+
metadataGroup("Acquisition", [
|
|
169
|
+
["Kind", info.timeKind ?? "Unavailable"],
|
|
170
|
+
["Steps", String(timeCount)],
|
|
171
|
+
...(info.time?.map((value, index) => [`Time ${index}`, formatTime(value)]) ?? []),
|
|
172
|
+
]),
|
|
173
|
+
metadataGroup("Georeferencing", [
|
|
174
|
+
["CRS", info.crs ?? "Undefined"],
|
|
175
|
+
["Raster type", info.pixelIsPoint ? "Pixel is point" : "Pixel is area"],
|
|
176
|
+
["Transform", info.transform?.map((value) => Number(value).toPrecision(10)).join(", ") ?? "Undefined", true],
|
|
177
|
+
]),
|
|
178
|
+
bandGroup(info.bands, bandCount),
|
|
179
|
+
);
|
|
180
|
+
element.metadatapanel.hidden = false;
|
|
181
|
+
element.metadatapanel.setAttribute("aria-hidden", "false");
|
|
182
|
+
requestAnimationFrame(() => element.metadatapanel.classList.add("open"));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function setupControls(info, name) {
|
|
186
|
+
const bandCount = info.shape.length === 3 ? info.shape[0] : info.shape[1];
|
|
187
|
+
const timeCount = info.shape.length === 4 ? info.shape[0] : 1;
|
|
188
|
+
element.sourcename.textContent = name;
|
|
189
|
+
element.sourceinfo.textContent = `${info.shape.join(" × ")} · ${info.dtype.name} · ${info.crs ?? "no CRS"}`;
|
|
190
|
+
element.timefield.hidden = info.time === null;
|
|
191
|
+
element.time.replaceChildren(...Array.from({ length: timeCount }, (_, time) => {
|
|
192
|
+
const option = document.createElement("option");
|
|
193
|
+
option.value = time;
|
|
194
|
+
option.textContent = info.time?.[time] !== undefined ? formatTime(info.time[time]) : `Time ${time}`;
|
|
195
|
+
return option;
|
|
196
|
+
}));
|
|
197
|
+
const red = namedBand(info.bands, /^B0?4\b/i, 0, bandCount);
|
|
198
|
+
const green = namedBand(info.bands, /^B0?3\b/i, 1, bandCount);
|
|
199
|
+
const blue = namedBand(info.bands, /^B0?2\b/i, 2, bandCount);
|
|
200
|
+
bandOptions(element.red, info.bands, bandCount, red);
|
|
201
|
+
bandOptions(element.green, info.bands, bandCount, green);
|
|
202
|
+
bandOptions(element.blue, info.bands, bandCount, blue);
|
|
203
|
+
element.bandfields.hidden = bandCount === 1;
|
|
204
|
+
element.controls.hidden = false;
|
|
205
|
+
showMetadata(info, name);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function supportedDtype(dtype) {
|
|
209
|
+
return !dtype.name.startsWith("complex")
|
|
210
|
+
&& !dtype.name.startsWith("float8")
|
|
211
|
+
&& !["float16", "bfloat16"].includes(dtype.name);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function projector(epsg) {
|
|
215
|
+
if (epsg === 4326) return (point) => point;
|
|
216
|
+
if (epsg === 3857) {
|
|
217
|
+
return ([x, y]) => [
|
|
218
|
+
(x / 6378137) * (180 / Math.PI),
|
|
219
|
+
(2 * Math.atan(Math.exp(y / 6378137)) - Math.PI / 2) * (180 / Math.PI),
|
|
220
|
+
];
|
|
221
|
+
}
|
|
222
|
+
const north = epsg >= 32601 && epsg <= 32660;
|
|
223
|
+
const south = epsg >= 32701 && epsg <= 32760;
|
|
224
|
+
if (!north && !south) throw new Error(`The viewer cannot project EPSG:${epsg} yet.`);
|
|
225
|
+
const zone = epsg - (south ? 32700 : 32600);
|
|
226
|
+
const proj4 = (await import(PROJ4_URL)).default;
|
|
227
|
+
const definition = `+proj=utm +zone=${zone}${south ? " +south" : ""} +datum=WGS84 +units=m +no_defs`;
|
|
228
|
+
return (point) => proj4(definition, "EPSG:4326", point);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function imageCorners(info) {
|
|
232
|
+
if (info.transform && info.epsg) {
|
|
233
|
+
const toLonLat = await projector(info.epsg);
|
|
234
|
+
return {
|
|
235
|
+
corners: affineCorners(info.transform, info.shape.at(-1), info.shape.at(-2)).map(toLonLat),
|
|
236
|
+
fallback: false,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const bounds = map.getBounds();
|
|
240
|
+
return {
|
|
241
|
+
corners: [bounds.getNorthWest(), bounds.getNorthEast(), bounds.getSouthEast(), bounds.getSouthWest()]
|
|
242
|
+
.map(({ lng, lat }) => [lng, lat]),
|
|
243
|
+
fallback: true,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function render() {
|
|
248
|
+
if (!dataset) return;
|
|
249
|
+
busy(true);
|
|
250
|
+
clearMessage();
|
|
251
|
+
setStatus("Rendering", "loading");
|
|
252
|
+
try {
|
|
253
|
+
const info = dataset.info();
|
|
254
|
+
if (!supportedDtype(info.dtype)) throw new Error(`${info.dtype.name} rendering is not supported yet.`);
|
|
255
|
+
const bandCount = info.shape.length === 3 ? info.shape[0] : info.shape[1];
|
|
256
|
+
const width = info.shape.at(-1);
|
|
257
|
+
const height = info.shape.at(-2);
|
|
258
|
+
if (width * height > MAX_PREVIEW_PIXELS) {
|
|
259
|
+
throw new Error("This image is too large for the browser preview (maximum 2048 × 2048 pixels).");
|
|
260
|
+
}
|
|
261
|
+
const bands = bandCount === 1
|
|
262
|
+
? [0]
|
|
263
|
+
: [element.red, element.green, element.blue].map((select) => Number(select.value));
|
|
264
|
+
const result = await dataset.read({
|
|
265
|
+
time: [Number(element.time.value || 0)],
|
|
266
|
+
bands,
|
|
267
|
+
});
|
|
268
|
+
const image = rasterRgba(result.data, width, height, bands.length);
|
|
269
|
+
const canvas = document.createElement("canvas");
|
|
270
|
+
canvas.width = image.width;
|
|
271
|
+
canvas.height = image.height;
|
|
272
|
+
canvas.getContext("2d").putImageData(new ImageData(image.rgba, image.width, image.height), 0, 0);
|
|
273
|
+
const placement = await imageCorners(info);
|
|
274
|
+
|
|
275
|
+
await mapReady;
|
|
276
|
+
if (map.getLayer(LAYER)) map.removeLayer(LAYER);
|
|
277
|
+
if (map.getSource(SOURCE)) map.removeSource(SOURCE);
|
|
278
|
+
map.addSource(SOURCE, { type: "image", url: canvas.toDataURL(), coordinates: placement.corners });
|
|
279
|
+
map.addLayer({
|
|
280
|
+
id: LAYER,
|
|
281
|
+
type: "raster",
|
|
282
|
+
source: SOURCE,
|
|
283
|
+
paint: { "raster-opacity": Number(element.opacity.value) / 100, "raster-fade-duration": 0 },
|
|
284
|
+
}, "labels");
|
|
285
|
+
const bounds = new maplibregl.LngLatBounds();
|
|
286
|
+
placement.corners.forEach((corner) => bounds.extend(corner));
|
|
287
|
+
const padding = window.innerWidth > 900
|
|
288
|
+
? { top: 70, right: 420, bottom: 70, left: 380 }
|
|
289
|
+
: 45;
|
|
290
|
+
map.fitBounds(bounds, { padding, maxZoom: 15, duration: 700 });
|
|
291
|
+
setStatus(placement.fallback ? "No CRS" : "Rendered", "ready");
|
|
292
|
+
} catch (error) {
|
|
293
|
+
showError(error.message ?? String(error));
|
|
294
|
+
} finally {
|
|
295
|
+
busy(false);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function openSource(source, name) {
|
|
300
|
+
busy(true);
|
|
301
|
+
clearMessage();
|
|
302
|
+
setStatus("Reading", "loading");
|
|
303
|
+
try {
|
|
304
|
+
dataset = await openRumi(source);
|
|
305
|
+
setupControls(dataset.info(), name);
|
|
306
|
+
await render();
|
|
307
|
+
} catch (error) {
|
|
308
|
+
dataset = undefined;
|
|
309
|
+
element.controls.hidden = true;
|
|
310
|
+
element.metadatapanel.hidden = true;
|
|
311
|
+
showError(error.message ?? String(error));
|
|
312
|
+
} finally {
|
|
313
|
+
busy(false);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function hideMetadata() {
|
|
318
|
+
element.metadatapanel.classList.remove("open");
|
|
319
|
+
element.metadatapanel.setAttribute("aria-hidden", "true");
|
|
320
|
+
setTimeout(() => {
|
|
321
|
+
if (!element.metadatapanel.classList.contains("open")) element.metadatapanel.hidden = true;
|
|
322
|
+
}, 200);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
element.urlform.addEventListener("submit", (event) => {
|
|
326
|
+
event.preventDefault();
|
|
327
|
+
const url = element.rumiurl.value.trim();
|
|
328
|
+
if (url) void openSource(url, url.split("/").pop() || url);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
element.fileinput.addEventListener("change", () => {
|
|
332
|
+
const [file] = element.fileinput.files;
|
|
333
|
+
if (file) void openSource(file, file.name);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
let dragDepth = 0;
|
|
337
|
+
document.addEventListener("dragenter", (event) => {
|
|
338
|
+
event.preventDefault();
|
|
339
|
+
dragDepth += 1;
|
|
340
|
+
element.dropzone.hidden = false;
|
|
341
|
+
});
|
|
342
|
+
document.addEventListener("dragover", (event) => event.preventDefault());
|
|
343
|
+
document.addEventListener("dragleave", (event) => {
|
|
344
|
+
event.preventDefault();
|
|
345
|
+
dragDepth = Math.max(0, dragDepth - 1);
|
|
346
|
+
if (dragDepth === 0) element.dropzone.hidden = true;
|
|
347
|
+
});
|
|
348
|
+
document.addEventListener("drop", (event) => {
|
|
349
|
+
event.preventDefault();
|
|
350
|
+
dragDepth = 0;
|
|
351
|
+
element.dropzone.hidden = true;
|
|
352
|
+
const file = [...event.dataTransfer.files].find((candidate) => candidate.name.endsWith(".rumi"));
|
|
353
|
+
if (file) void openSource(file, file.name);
|
|
354
|
+
else showError("Drop a .rumi file.");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
element.render.addEventListener("click", () => void render());
|
|
358
|
+
element.opacity.addEventListener("input", () => {
|
|
359
|
+
element.opacityvalue.value = `${element.opacity.value}%`;
|
|
360
|
+
if (map.getLayer(LAYER)) map.setPaintProperty(LAYER, "raster-opacity", Number(element.opacity.value) / 100);
|
|
361
|
+
});
|
|
362
|
+
element.close.addEventListener("click", hideMetadata);
|
|
363
|
+
element.showmetadata.addEventListener("click", () => {
|
|
364
|
+
if (!dataset) return;
|
|
365
|
+
element.metadatapanel.hidden = false;
|
|
366
|
+
element.metadatapanel.setAttribute("aria-hidden", "false");
|
|
367
|
+
requestAnimationFrame(() => element.metadatapanel.classList.add("open"));
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const initialUrl = new URL(window.location.href).searchParams.get("url");
|
|
371
|
+
if (initialUrl) {
|
|
372
|
+
element.rumiurl.value = initialUrl;
|
|
373
|
+
void openSource(initialUrl, initialUrl.split("/").pop() || initialUrl);
|
|
374
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 109.05 22.32" role="img" aria-label="Asterisk Labs">
|
|
2
|
+
<style>
|
|
3
|
+
path[fill="#E6EDF3"] { fill: #1a1a1a }
|
|
4
|
+
path[fill="#0D1117"] { fill: #fff }
|
|
5
|
+
text { fill: #1a1a1a }
|
|
6
|
+
</style>
|
|
7
|
+
<path fill="#E6EDF3" d="M9.08,3.27c.38-.22,1-.22,1.38,0l5.79,3.35c.38.22.69.76.69,1.2v6.69c0,.44-.31.98-.69,1.2l-5.79,3.35c-.38.22-1,.22-1.38,0l-5.79-3.35c-.38-.22-.69-.76-.69-1.2v-6.69c0-.44.31-.98.69-1.2l5.79-3.35Z"/>
|
|
8
|
+
<path fill="#E6EDF3" d="M9.42.08c.19-.11.5-.11.69,0l1.9,1.1c.19.11.35.38.35.6v2.19c0,.22-.16.49-.35.6l-1.9,1.1c-.19.11-.5.11-.69,0l-1.9-1.1c-.19-.11-.35-.38-.35-.6V1.78c0-.22.16-.49.35-.6L9.42.08Z"/>
|
|
9
|
+
<path fill="#E6EDF3" d="M16.6,12.49c.19-.11.5-.11.69,0l1.9,1.1c.19.11.35.38.35.6v2.19c0,.22-.16.49-.35.6l-1.9,1.1c-.19.11-.5.11-.69,0l-1.9-1.1c-.19-.11-.35-.38-.35-.6v-2.19c0-.22.16-.49.35-.6l1.9-1.1Z"/>
|
|
10
|
+
<path fill="#E6EDF3" d="M2.25,4.18c.19-.11.5-.11.69,0l1.9,1.1c.19.11.35.38.35.6v2.19c0,.22-.16.49-.35.6l-1.9,1.1c-.19.11-.5.11-.69,0l-1.9-1.1c-.19-.11-.35-.38-.35-.6v-2.19c0-.22.16-.49.35-.6l1.9-1.1Z"/>
|
|
11
|
+
<path fill="#E6EDF3" d="M16.6,4.18c.19-.11.5-.11.69,0l1.9,1.1c.19.11.35.38.35.6v2.19c0,.22-.16.49-.35.6l-1.9,1.1c-.19.11-.5.11-.69,0l-1.9-1.1c-.19-.11-.35-.38-.35-.6v-2.19c0-.22.16-.49.35-.6l1.9-1.1Z"/>
|
|
12
|
+
<path fill="#E6EDF3" d="M9.42,16.65c.19-.11.5-.11.69,0l1.9,1.1c.19.11.35.38.35.6v2.19c0,.22-.16.49-.35.6l-1.9,1.1c-.19.11-.5.11-.69,0l-1.9-1.1c-.19-.11-.35-.38-.35-.6v-2.19c0-.22.16-.49.35-.6l1.9-1.1Z"/>
|
|
13
|
+
<path fill="#E6EDF3" d="M2.25,12.49c.19-.11.5-.11.69,0l1.9,1.1c.19.11.35.38.35.6v2.19c0,.22-.16.49-.35.6l-1.9,1.1c-.19.11-.5.11-.69,0l-1.9-1.1c-.19-.11-.35-.38-.35-.6v-2.19c0-.22.16-.49.35-.6l1.9-1.1Z"/>
|
|
14
|
+
<path fill="#0D1117" d="M9.1,6.25c.36-.21.96-.21,1.32,0l3.26,1.88c.36.21.66.73.66,1.15v3.77c0,.42-.3.94-.66,1.15l-3.26,1.88c-.36.21-.96.21-1.32,0l-3.26-1.88c-.36-.21-.66-.73-.66-1.15v-3.77c0-.42.3-.94.66-1.15l3.26-1.88Z"/>
|
|
15
|
+
<path fill="#F8CE00" d="M9.76,1.04h.02l1.58.91v1.84l-1.58.91h-.02l-1.58-.91V1.95l1.58-.91Z"/>
|
|
16
|
+
<path fill="#A2FFA2" d="M2.58,5.14h.02l1.58.91v1.84L2.6,8.8h-.02L1,7.89V6.05l1.58-.91Z"/>
|
|
17
|
+
<path fill="#4E04EB" d="M16.93,5.14h.02l1.58.91v1.84l-1.58.91h-.02l-1.58-.91V6.05l1.58-.91Z"/>
|
|
18
|
+
<path fill="#4E04EB" d="M9.76,17.61h.02l1.58.91v1.84l-1.58.91h-.02l-1.58-.91v-1.84l1.58-.91Z"/>
|
|
19
|
+
<path fill="#F8CE00" d="M2.58,13.45h.02l1.58.91v1.84l-1.58.91h-.02L1,16.2v-1.84l1.58-.91Z"/>
|
|
20
|
+
<path fill="#A2FFA2" d="M16.93,13.45h.02l1.58.91v1.84l-1.58.91h-.02l-1.58-.91v-1.84l1.58-.91Z"/>
|
|
21
|
+
<text font-family="Futura,'Century Gothic',system-ui,sans-serif" font-size="15.36" font-weight="500" transform="translate(23.89 15.47)"><tspan x="0" y="0">asterisk labs</tspan></text>
|
|
22
|
+
</svg>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="description" content="Open and inspect a Rumi image on a MapLibre map.">
|
|
7
|
+
<title>Rumi viewer</title>
|
|
8
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.9.0/dist/maplibre-gl.css">
|
|
9
|
+
<link rel="stylesheet" href="./styles.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<header class="topbar">
|
|
13
|
+
<a class="brand" href="https://asterisk.coop/rumi/" target="_blank" rel="noopener noreferrer" aria-label="Rumi home">
|
|
14
|
+
<img src="./rumi-mark.svg" alt="">
|
|
15
|
+
<span><strong>RUMI</strong><small>viewer</small></span>
|
|
16
|
+
</a>
|
|
17
|
+
|
|
18
|
+
<form id="url-form" class="source-picker">
|
|
19
|
+
<input id="rumi-url" type="text" placeholder="https://example.com/image.rumi" aria-label="Rumi file URL" spellcheck="false">
|
|
20
|
+
<button class="primary" type="submit">Open</button>
|
|
21
|
+
<label class="file-button" for="file-input">
|
|
22
|
+
Local file
|
|
23
|
+
<input id="file-input" type="file" accept=".rumi,application/octet-stream">
|
|
24
|
+
</label>
|
|
25
|
+
</form>
|
|
26
|
+
|
|
27
|
+
<div id="status" class="status ready" role="status"><i></i><span>Ready</span></div>
|
|
28
|
+
</header>
|
|
29
|
+
|
|
30
|
+
<main>
|
|
31
|
+
<div id="map" aria-label="Rumi image map"></div>
|
|
32
|
+
<div id="message" class="message" hidden></div>
|
|
33
|
+
|
|
34
|
+
<section id="controls" class="render-panel" aria-labelledby="render-title" hidden>
|
|
35
|
+
<header>
|
|
36
|
+
<div><p>Display</p><h2 id="render-title">Band composition</h2></div>
|
|
37
|
+
<button id="show-metadata" class="panel-link" type="button">Metadata</button>
|
|
38
|
+
</header>
|
|
39
|
+
<div class="source-summary">
|
|
40
|
+
<strong id="source-name"></strong>
|
|
41
|
+
<span id="source-info"></span>
|
|
42
|
+
</div>
|
|
43
|
+
<div id="time-field" class="field" hidden>
|
|
44
|
+
<label for="time">Time</label>
|
|
45
|
+
<select id="time"></select>
|
|
46
|
+
</div>
|
|
47
|
+
<div id="band-fields" class="band-grid">
|
|
48
|
+
<div class="field"><label for="red">Red</label><select id="red"></select></div>
|
|
49
|
+
<div class="field"><label for="green">Green</label><select id="green"></select></div>
|
|
50
|
+
<div class="field"><label for="blue">Blue</label><select id="blue"></select></div>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="opacity-field">
|
|
53
|
+
<label for="opacity">Opacity</label>
|
|
54
|
+
<output id="opacity-value">90%</output>
|
|
55
|
+
<input id="opacity" type="range" min="0" max="100" value="90">
|
|
56
|
+
</div>
|
|
57
|
+
<button id="render" class="render-button" type="button">Render bands</button>
|
|
58
|
+
</section>
|
|
59
|
+
|
|
60
|
+
<aside id="metadata-panel" class="metadata-panel" aria-labelledby="metadata-title" aria-hidden="true" hidden>
|
|
61
|
+
<header>
|
|
62
|
+
<div><p>Rumi metadata</p><h2 id="metadata-title">Image</h2></div>
|
|
63
|
+
<button id="close" class="icon-button" type="button" aria-label="Close metadata">×</button>
|
|
64
|
+
</header>
|
|
65
|
+
<div id="metadata-body" class="metadata-body"></div>
|
|
66
|
+
</aside>
|
|
67
|
+
|
|
68
|
+
<div id="drop-zone" class="drop-overlay" hidden>
|
|
69
|
+
<img src="./rumi-mark.svg" alt="">
|
|
70
|
+
<strong>Drop a .rumi file</strong>
|
|
71
|
+
<span>It stays in this browser.</span>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<div id="busy" class="loading" hidden>
|
|
75
|
+
<div class="rumi-loader" aria-hidden="true">
|
|
76
|
+
<img src="./rumi-mark.svg" alt="">
|
|
77
|
+
<div><span>🛰️</span></div>
|
|
78
|
+
</div>
|
|
79
|
+
<strong>Reading Rumi</strong>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<footer class="colophon">
|
|
83
|
+
<span>Made with</span>
|
|
84
|
+
<span class="heart" aria-label="love" role="img">♥</span>
|
|
85
|
+
<span>by</span>
|
|
86
|
+
<a class="by" href="https://asterisk.coop" target="_blank" rel="noopener noreferrer" aria-label="Asterisk Labs">
|
|
87
|
+
<img src="./asterisk-mark.svg" alt="Asterisk Labs">
|
|
88
|
+
</a>
|
|
89
|
+
</footer>
|
|
90
|
+
</main>
|
|
91
|
+
|
|
92
|
+
<script type="importmap">
|
|
93
|
+
{
|
|
94
|
+
"imports": {
|
|
95
|
+
"@asterisk-labs/geozl": "/geozl/geozl_api.js"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
</script>
|
|
99
|
+
<script type="module" src="./app.js"></script>
|
|
100
|
+
</body>
|
|
101
|
+
</html>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const MAX_SAMPLES = 50_000;
|
|
2
|
+
|
|
3
|
+
function numeric(value) {
|
|
4
|
+
return typeof value === "bigint" ? Number(value) : value;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function limits(data, offset, pixels) {
|
|
8
|
+
const step = Math.max(1, Math.ceil(pixels / MAX_SAMPLES));
|
|
9
|
+
const sample = [];
|
|
10
|
+
for (let index = 0; index < pixels; index += step) {
|
|
11
|
+
const value = numeric(data[offset + index]);
|
|
12
|
+
if (Number.isFinite(value)) sample.push(value);
|
|
13
|
+
}
|
|
14
|
+
if (sample.length === 0) return [0, 1];
|
|
15
|
+
sample.sort((a, b) => a - b);
|
|
16
|
+
const low = sample[Math.floor((sample.length - 1) * 0.02)];
|
|
17
|
+
const high = sample[Math.ceil((sample.length - 1) * 0.98)];
|
|
18
|
+
return low === high ? [low - 0.5, high + 0.5] : [low, high];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function byte(value, [low, high]) {
|
|
22
|
+
if (!Number.isFinite(value)) return 0;
|
|
23
|
+
return Math.round(Math.max(0, Math.min(1, (value - low) / (high - low))) * 255);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function rasterRgba(data, width, height, planes, maxSize = 2048) {
|
|
27
|
+
const pixels = width * height;
|
|
28
|
+
if (planes !== 1 && planes !== 3) throw new Error("rendering needs one or three bands");
|
|
29
|
+
const scale = Math.min(1, maxSize / Math.max(width, height));
|
|
30
|
+
const outWidth = Math.max(1, Math.round(width * scale));
|
|
31
|
+
const outHeight = Math.max(1, Math.round(height * scale));
|
|
32
|
+
const stretches = Array.from({ length: planes }, (_, plane) => limits(data, plane * pixels, pixels));
|
|
33
|
+
const rgba = new Uint8ClampedArray(outWidth * outHeight * 4);
|
|
34
|
+
|
|
35
|
+
for (let y = 0; y < outHeight; y += 1) {
|
|
36
|
+
const sourceY = Math.min(height - 1, Math.floor(y / scale));
|
|
37
|
+
for (let x = 0; x < outWidth; x += 1) {
|
|
38
|
+
const sourceX = Math.min(width - 1, Math.floor(x / scale));
|
|
39
|
+
const sourcePixel = sourceY * width + sourceX;
|
|
40
|
+
const target = (y * outWidth + x) * 4;
|
|
41
|
+
if (planes === 1) {
|
|
42
|
+
const value = byte(numeric(data[sourcePixel]), stretches[0]);
|
|
43
|
+
rgba[target] = value;
|
|
44
|
+
rgba[target + 1] = value;
|
|
45
|
+
rgba[target + 2] = value;
|
|
46
|
+
} else {
|
|
47
|
+
for (let channel = 0; channel < 3; channel += 1) {
|
|
48
|
+
rgba[target + channel] = byte(
|
|
49
|
+
numeric(data[channel * pixels + sourcePixel]),
|
|
50
|
+
stretches[channel],
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
rgba[target + 3] = 255;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { rgba, width: outWidth, height: outHeight, stretches };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function affineCorners(transform, width, height) {
|
|
61
|
+
const [a, b, c, d, e, f] = transform;
|
|
62
|
+
return [[0, 0], [width, 0], [width, height], [0, height]].map(
|
|
63
|
+
([column, row]) => [a * column + b * row + c, d * column + e * row + f],
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 104 104" role="img" aria-label="rumi">
|
|
2
|
+
<style>
|
|
3
|
+
polygon { stroke: #aeb2b6; stroke-opacity: .72; stroke-width: 1.35; stroke-linejoin: round; fill: #474a50; animation: lit 6s step-end infinite; }
|
|
4
|
+
.nw { --on: #7457ff; animation-delay: 0s }
|
|
5
|
+
.ne { --on: #ffd54a; animation-delay: 1.5s }
|
|
6
|
+
.se { --on: #91f2bc; animation-delay: 3s }
|
|
7
|
+
.sw { --on: #ff7a4f; animation-delay: 4.5s }
|
|
8
|
+
@keyframes lit { 0% { fill: var(--on) } 25%, 100% { fill: #474a50 } }
|
|
9
|
+
@media (prefers-reduced-motion: reduce) { polygon { animation: none } }
|
|
10
|
+
</style>
|
|
11
|
+
<polygon class="nw" points="0,0 47,0 43,38 0,32"/>
|
|
12
|
+
<polygon class="ne" points="47,0 100,0 100,55 70,50 43,38"/>
|
|
13
|
+
<polygon class="se" points="70,50 100,55 100,100 58,100 50,72"/>
|
|
14
|
+
<polygon class="sw" points="0,32 43,38 70,50 50,72 58,100 0,100"/>
|
|
15
|
+
</svg>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { access, stat } from "node:fs/promises";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const javascript = path.resolve(here, "..");
|
|
9
|
+
let installedGeoZL;
|
|
10
|
+
try {
|
|
11
|
+
installedGeoZL = path.dirname(fileURLToPath(import.meta.resolve("@asterisk-labs/geozl")));
|
|
12
|
+
} catch {}
|
|
13
|
+
const candidates = [
|
|
14
|
+
process.env.GEOZL_WASM_DIR,
|
|
15
|
+
installedGeoZL,
|
|
16
|
+
path.resolve(here, "../../../../geozl/core/build-wasm/wasm"),
|
|
17
|
+
path.resolve(here, "../../../extern/geozl/core/build-wasm/wasm"),
|
|
18
|
+
].filter(Boolean);
|
|
19
|
+
let geozl;
|
|
20
|
+
for (const candidate of candidates) {
|
|
21
|
+
try {
|
|
22
|
+
await access(path.join(candidate, "geozl_api.js"));
|
|
23
|
+
geozl = candidate;
|
|
24
|
+
break;
|
|
25
|
+
} catch {}
|
|
26
|
+
}
|
|
27
|
+
if (!geozl) throw new Error("GeoZL WASM build not found; set GEOZL_WASM_DIR");
|
|
28
|
+
|
|
29
|
+
const sampleFlag = process.argv.indexOf("--sample");
|
|
30
|
+
const sample = sampleFlag >= 0 ? path.resolve(process.argv[sampleFlag + 1]) : undefined;
|
|
31
|
+
const port = Number(process.env.PORT || 4321);
|
|
32
|
+
|
|
33
|
+
function inside(root, requestPath) {
|
|
34
|
+
const file = path.resolve(root, requestPath);
|
|
35
|
+
return file === root || file.startsWith(`${root}${path.sep}`) ? file : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function target(url) {
|
|
39
|
+
if (url === "/sample.rumi" && sample) return sample;
|
|
40
|
+
if (url.startsWith("/rumi/")) return inside(javascript, url.slice(6));
|
|
41
|
+
if (url.startsWith("/geozl/")) return inside(geozl, url.slice(7));
|
|
42
|
+
return inside(here, url === "/" ? "index.html" : url.slice(1));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
http.createServer(async (request, response) => {
|
|
46
|
+
try {
|
|
47
|
+
const file = target(new URL(request.url, "http://localhost").pathname);
|
|
48
|
+
if (!file) return void response.writeHead(404).end("not found");
|
|
49
|
+
const info = await stat(file);
|
|
50
|
+
const match = /^bytes=(\d+)-(\d+)$/.exec(request.headers.range ?? "");
|
|
51
|
+
const extension = path.extname(file);
|
|
52
|
+
const type = extension === ".wasm" ? "application/wasm"
|
|
53
|
+
: extension === ".js" || extension === ".mjs" ? "text/javascript"
|
|
54
|
+
: extension === ".css" ? "text/css"
|
|
55
|
+
: extension === ".svg" ? "image/svg+xml"
|
|
56
|
+
: extension === ".html" ? "text/html; charset=utf-8"
|
|
57
|
+
: "application/octet-stream";
|
|
58
|
+
const headers = { "Accept-Ranges": "bytes", "Access-Control-Allow-Origin": "*", "Content-Type": type };
|
|
59
|
+
if (match) {
|
|
60
|
+
const start = Number(match[1]);
|
|
61
|
+
const end = Number(match[2]);
|
|
62
|
+
if (start > end || end >= info.size) {
|
|
63
|
+
return void response.writeHead(416, { "Content-Range": `bytes */${info.size}` }).end();
|
|
64
|
+
}
|
|
65
|
+
response.writeHead(206, {
|
|
66
|
+
...headers,
|
|
67
|
+
"Content-Length": end - start + 1,
|
|
68
|
+
"Content-Range": `bytes ${start}-${end}/${info.size}`,
|
|
69
|
+
});
|
|
70
|
+
createReadStream(file, { start, end }).pipe(response);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
response.writeHead(200, { ...headers, "Content-Length": info.size });
|
|
74
|
+
createReadStream(file).pipe(response);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
response.writeHead(404).end(error.message ?? String(error));
|
|
77
|
+
}
|
|
78
|
+
}).listen(port, "127.0.0.1", () => {
|
|
79
|
+
console.log(`Rumi playground: http://127.0.0.1:${port}/`);
|
|
80
|
+
});
|