From e69803797ebffca5eb343d054c6335fe26015af7 Mon Sep 17 00:00:00 2001 From: Hanbyul Jo Date: Thu, 29 May 2025 14:59:15 -0400 Subject: [PATCH 1/8] Add Titiler example(exp) --- src/Titiler.tsx | 177 ++++++++++++++++++++++++++++++++++++++++++++++++ src/npy.js | 130 +++++++++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 src/Titiler.tsx create mode 100644 src/npy.js diff --git a/src/Titiler.tsx b/src/Titiler.tsx new file mode 100644 index 0000000..b9478c0 --- /dev/null +++ b/src/Titiler.tsx @@ -0,0 +1,177 @@ +import { useState } from "react"; +import { Map, NavigationControl, useControl } from "react-map-gl/maplibre"; +import { TileLayer } from "@deck.gl/geo-layers"; +import type { _TileLoadProps } from "@deck.gl/geo-layers"; + +import { MapboxOverlay as DeckOverlay } from "@deck.gl/mapbox"; + +import { parseNpy } from "./npy"; +import NumericDataLayer from "@/layers/NumericDataLayer"; +import type { NumericDataPickingInfo } from "@/layers/NumericDataLayer/types"; +import Panel from "@/components/Panel"; +import Description from "@/components/Description"; +import Dropdown from "@/components/ui/Dropdown"; +import RangeSlider from "@/components/ui/RangeSlider"; +import CheckBox from "@/components/ui/Checkbox"; + +import "maplibre-gl/dist/maplibre-gl.css"; +import "./App.css"; + +const INITIAL_VIEW_STATE = { + latitude: 1.3567, + longitude: 172.933, + zoom: 15, + maxZoom: 20, +}; + +const MAP_STYLE = + "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"; + +//@ts-expect-error ignoring for now +function DeckGLOverlay(props) { + const overlay = useControl(() => new DeckOverlay(props)); + overlay.setProps(props); + return null; +} + +function App() { + const [selectedColormap, setSelectedColormap] = useState("viridis"); + const [minMax, setMinMax] = useState<{ min: number; max: number }>({ + min: 3000, + max: 18000, + }); + const [bandRange, setBandRange] = useState([ + { value: 0, label: "0" }, + ]); + const [selectedBand, setSelectedBand] = useState(0); + + const [showTooltip, setShowTooltip] = useState(true); + + async function getTileData({ index, signal }: _TileLoadProps) { + if (signal?.aborted) { + console.error("Signal aborted: ", signal); + return null; + } + const { z, x, y } = index; + //titiler.xyz + const url = `https://titiler.xyz/cog/tiles/WebMercatorQuad/${z}/${x}/${y}@1x?format=npy&url=https://storage.googleapis.com/open-cogs/stac-examples/20201211_223832_CS2_analytic.tif`; + const resp = await fetch(url); + if (!resp.ok) { + return null; + } + // @ts-expect-error npy parser not typed yet + const { dtype, data, header } = parseNpy(await resp.arrayBuffer()); + + if (bandRange !== header.shape[0]) + setBandRange( + new Array(header.shape[0]) + .fill(0) + .map((_, idx) => ({ label: idx.toString(), value: idx })) + ); + return { dtype, data }; + } + + const layers = [ + new TileLayer({ + id: "TileLayer", + // data: 'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png', + + /* props from TileLayer class */ + // TilesetClass: null, + // debounceTime: 0, + // extent: null, + getTileData, + // maxCacheByteSize: null, + // maxCacheSize: null, + // maxRequests: 6, + maxZoom: 16, + minZoom: 0, + // onTileError: null, + // onTileLoad: null, + // onTileUnload: null, + // onViewportLoad: null, + // refinementStrategy: 'best-available', + // Any better way to do this? + updateTriggers: { + renderSubLayers: [selectedColormap, minMax, selectedBand], + }, + renderSubLayers: (props) => { + const { data } = props.data; + const { boundingBox } = props.tile; + // Cast into float32 (?? integer texture) + const slicedData = new Float32Array( + data.slice(256 * 256 * selectedBand, 256 * 256 * (selectedBand + 1)) + ); + return new NumericDataLayer(props, { + data: undefined, + colormap_image: `/colormaps/${selectedColormap}.png`, + min: minMax.min, + max: minMax.max, + tileSize: 256, + imageData: slicedData, + bounds: [ + boundingBox[0][0], + boundingBox[0][1], + boundingBox[1][0], + boundingBox[1][1], + ], + pickable: true, + }); + }, + tileSize: 256, + // zRange: null, + // zoomOffset: 0, + + /* props inherited from Layer class */ + + // autoHighlight: false, + // coordinateOrigin: [0, 0, 0], + // coordinateSystem: COORDINATE_SYSTEM.LNGLAT, + // highlightColor: [0, 0, 128, 128], + // modelMatrix: null, + // opacity: 1, + + // visible: true, + // wrapLongitude: false, + }), + ]; + + const deckProps = { + layers, + getTooltip: (info: NumericDataPickingInfo) => { + return showTooltip ? info.dataValue && `${info.dataValue}` : null; + }, + }; + + return ( + <> + + 1 + + + + + + + + + + + + ); +} + +export default App; diff --git a/src/npy.js b/src/npy.js new file mode 100644 index 0000000..abe5b3c --- /dev/null +++ b/src/npy.js @@ -0,0 +1,130 @@ +// https://github.com/kylebarron/deck.gl-raster/blob/master/src/deckgl/raster-layer/raster-layer.js + +// \x93NUMPY +const NPY_MAGIC = new Uint8Array([147, 78, 85, 77, 80, 89]); + +function systemIsLittleEndian() { + const a = new Uint32Array([0x12345678]); + const b = new Uint8Array(a.buffer, a.byteOffset, a.byteLength); + return !(b[0] == 0x12); +} + +const LITTLE_ENDIAN_OS = systemIsLittleEndian(); + +// The basic string format consists of 3 parts: +// - a character describing the byteorder of the data (<: little-endian, >: big-endian, |: not-relevant) +// - a character code giving the basic type of the array +// - an integer providing the number of bytes the type uses. +// https://numpy.org/doc/stable/reference/arrays.interface.html +const DTYPES = { + u1: { + name: "uint8", + arrayConstructor: Uint8Array, + }, + i1: { + name: "int8", + arrayConstructor: Int8Array, + }, + u2: { + name: "uint16", + arrayConstructor: Uint16Array, + }, + i2: { + name: "int16", + arrayConstructor: Int16Array, + }, + u4: { + name: "uint32", + arrayConstructor: Int32Array, + }, + i4: { + name: "int32", + arrayConstructor: Int32Array, + }, + f4: { + name: "float32", + arrayConstructor: Float32Array, + }, + f8: { + name: "float64", + arrayConstructor: Float64Array, + }, +}; + +export function parseNpy(arrayBuffer) { + if (!arrayBuffer) { + return null; + } + + const view = new DataView(arrayBuffer); + + const magic = new Uint8Array(arrayBuffer, 0, 6); + if (!arrayEqual(magic, NPY_MAGIC)) { + console.warn("NPY Magic not matched!"); + } + + const majorVersion = view.getUint8(6); + // const minorVersion = view.getUint8(7); + + let offset = 8; + let headerLength; + if (majorVersion >= 2) { + headerLength = view.getUint32(8, true); + offset += 4; + } else { + headerLength = view.getUint16(8, true); + offset += 2; + } + + const encoding = majorVersion <= 2 ? "latin1" : "utf-8"; + const decoder = new TextDecoder(encoding); + const headerArray = new Uint8Array(arrayBuffer, offset, headerLength); + const headerText = decoder.decode(headerArray); + offset += headerLength; + + const header = JSON.parse( + headerText + .replace(/'/g, '"') + .replace("False", "false") + .replace("(", "[") + .replace(/,*\),*/g, "]") + ); + + const npy_dtype = header.descr; + const dtype = DTYPES[npy_dtype.slice(1, 3)]; + if (!dtype) { + console.warn(`Decoding of npy dtype not implemented: ${npy_dtype}`); + return null; + } + + const data = new dtype["arrayConstructor"](arrayBuffer, offset); + + // Swap endianness if needed + if ( + (npy_dtype[0] === ">" && LITTLE_ENDIAN_OS) || + (npy_dtype[0] === " Date: Tue, 6 May 2025 16:28:28 -0400 Subject: [PATCH 2/8] Datetime selection --- src/App.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index e7becdc..384ff26 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -162,7 +162,6 @@ function App() { From d5a756f63d9dbc2d6613c50f30f9e2715ea6075f Mon Sep 17 00:00:00 2001 From: Hanbyul Jo Date: Thu, 29 May 2025 15:08:23 -0400 Subject: [PATCH 3/8] Add React router --- package-lock.json | 120 ++++++++++++++++++++++++++++++++++++++++++++-- package.json | 1 + src/main.tsx | 10 +++- 3 files changed, 126 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7a89790..d195e8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-map-gl": "^8.0.2", + "react-router": "^7.5.3", "zarrita": "^0.5.1" }, "devDependencies": { @@ -4669,6 +4670,14 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "engines": { + "node": ">=18" + } + }, "node_modules/core-assert": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz", @@ -6672,6 +6681,28 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.5.3.tgz", + "integrity": "sha512-3iUDM4/fZCQ89SXlDa+Ph3MevBrozBAI655OAfWQlTm9nBR0IKlrmNwFow5lPHttbwvITZfkeeeZFP6zt3F7pw==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0", + "turbo-stream": "2.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -6831,6 +6862,11 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==" + }, "node_modules/set-value": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", @@ -7124,6 +7160,48 @@ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "dev": true }, + "node_modules/tinyglobby": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "dev": true, + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinypool": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", @@ -7185,6 +7263,11 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "node_modules/turbo-stream": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz", + "integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -7342,14 +7425,17 @@ "integrity": "sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA==" }, "node_modules/vite": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz", - "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "dependencies": { "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", "postcss": "^8.5.3", - "rollup": "^4.30.1" + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" @@ -7434,6 +7520,32 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vitest": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.9.tgz", diff --git a/package.json b/package.json index 9aedc3c..45c8f16 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-map-gl": "^8.0.2", + "react-router": "^7.5.3", "zarrita": "^0.5.1" }, "devDependencies": { diff --git a/src/main.tsx b/src/main.tsx index 8f027f3..703441e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,11 +1,19 @@ import { createRoot } from "react-dom/client"; import { Provider } from "@/components/ui/Provider"; import { defaultSystem } from "@chakra-ui/react"; +import { BrowserRouter, Routes, Route } from "react-router"; + import "./index.css"; import App from "./App.tsx"; +import Animation from "./Animation.tsx"; createRoot(document.getElementById("root")!).render( - + + + } /> + } /> + + ); From 1916cc99506951e768348652c73cdd559191c8fd Mon Sep 17 00:00:00 2001 From: Hanbyul Jo Date: Thu, 29 May 2025 15:12:43 -0400 Subject: [PATCH 4/8] Add Titiler example --- src/main.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index 703441e..666b2e6 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,14 +5,16 @@ import { BrowserRouter, Routes, Route } from "react-router"; import "./index.css"; import App from "./App.tsx"; -import Animation from "./Animation.tsx"; +// import Animation from "./Animation.tsx"; +import Titiler from "./Titiler.tsx"; createRoot(document.getElementById("root")!).render( } /> - } /> + {/* } /> */} + } /> From 0f49227ee4e938ac0acf9214daeb5564d4efa9c0 Mon Sep 17 00:00:00 2001 From: Hanbyul Jo Date: Thu, 29 May 2025 15:32:16 -0400 Subject: [PATCH 5/8] Fix types for UI, move util file #4 --- src/App.tsx | 3 +- src/Titiler.tsx | 16 +++++----- src/components/ui/Dropdown.tsx | 58 ++++++++++++++++++++++------------ src/{ => utils}/npy.js | 0 4 files changed, 48 insertions(+), 29 deletions(-) rename src/{ => utils}/npy.js (100%) diff --git a/src/App.tsx b/src/App.tsx index 384ff26..fc7bcdd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -158,10 +158,11 @@ function App() { - + onChange={setSelectedColormap} /> diff --git a/src/Titiler.tsx b/src/Titiler.tsx index b9478c0..58d3dee 100644 --- a/src/Titiler.tsx +++ b/src/Titiler.tsx @@ -4,13 +4,13 @@ import { TileLayer } from "@deck.gl/geo-layers"; import type { _TileLoadProps } from "@deck.gl/geo-layers"; import { MapboxOverlay as DeckOverlay } from "@deck.gl/mapbox"; - -import { parseNpy } from "./npy"; +// @ts-expect-error npy is not typed yet +import { parseNpy } from "./utils/npy"; import NumericDataLayer from "@/layers/NumericDataLayer"; import type { NumericDataPickingInfo } from "@/layers/NumericDataLayer/types"; import Panel from "@/components/Panel"; -import Description from "@/components/Description"; import Dropdown from "@/components/ui/Dropdown"; +import type { Option } from "@/components/ui/Dropdown"; import RangeSlider from "@/components/ui/RangeSlider"; import CheckBox from "@/components/ui/Checkbox"; @@ -40,7 +40,7 @@ function App() { min: 3000, max: 18000, }); - const [bandRange, setBandRange] = useState([ + const [bandRange, setBandRange] = useState[]>([ { value: 0, label: "0" }, ]); const [selectedBand, setSelectedBand] = useState(0); @@ -59,7 +59,7 @@ function App() { if (!resp.ok) { return null; } - // @ts-expect-error npy parser not typed yet + const { dtype, data, header } = parseNpy(await resp.arrayBuffer()); if (bandRange !== header.shape[0]) @@ -155,14 +155,14 @@ function App() { - - + onChange={setSelectedColormap} /> - label="Select band " options={bandRange} defaultValue={selectedBand} diff --git a/src/components/ui/Dropdown.tsx b/src/components/ui/Dropdown.tsx index 14f0aa0..b5ef5ee 100644 --- a/src/components/ui/Dropdown.tsx +++ b/src/components/ui/Dropdown.tsx @@ -3,39 +3,57 @@ import { Portal, Select, createListCollection } from "@chakra-ui/react"; import { baseZIndex } from "../Panel"; -type Option = { - value: string; +export type Option = { + value: T; label: string; }; -interface DropdownProps { - onChange: (colormapName: string) => void; - options?: Option[]; - defaultValue?: string; +interface DropdownProps { + label?: string; + onChange: (value: T) => void; + options?: Option[]; + defaultValue?: T; } -const colormapOptions = [ +const colormapOptions: Option[] = [ { label: "VIRIDIS", value: "viridis" }, { label: "CIVIDIS", value: "cividis" }, ]; -const Dropdown = ({ +const Dropdown = ({ onChange, - options = colormapOptions, - defaultValue = "viridis", -}: DropdownProps) => { - const [selected, setSelected] = useState(defaultValue); + label = "Select colormap", + options = colormapOptions as Option[], + defaultValue, +}: DropdownProps) => { + // Convert the value to string for Chakra UI Select + const defaultStringValue = + defaultValue?.toString() ?? options[0]?.value?.toString() ?? ""; + const [selected, setSelected] = useState(defaultStringValue); + + // Create a map to convert string back to original type + const valueMap = new Map(); + options.forEach((option) => { + valueMap.set(option.value.toString(), option.value); + }); const colorMaps = createListCollection({ - items: options, + items: options.map((option) => ({ + label: option.label, + value: option.value.toString(), // Convert to string for Chakra UI + })), }); - const handleChange = (event: Select.ValueChangeDetails