diff --git a/package.json b/package.json index 21ed77e..08ad795 100644 --- a/package.json +++ b/package.json @@ -37,10 +37,14 @@ "docs": "typedoc" }, "dependencies": { + "@chunkd/middleware": "^11.3.0", + "@chunkd/source": "^11.4.0", + "@chunkd/source-http": "^11.4.0", "@deck.gl/core": "^9.3.1", "@deck.gl/layers": "^9.3.1", "@deck.gl/mapbox": "^9.3.1", "@developmentseed/deck.gl-geotiff": "^0.6.0", + "@developmentseed/geotiff": "^0.6.1", "@developmentseed/proj": "^0.6.1", "@devseed-ui/collecticons-chakra": "^4.0.0", "@duckdb/duckdb-wasm": "^1.32.0", diff --git a/src/components/visualization.tsx b/src/components/visualization.tsx index ce93a34..581f708 100644 --- a/src/components/visualization.tsx +++ b/src/components/visualization.tsx @@ -1,5 +1,7 @@ +import { useGeoTIFF } from "@/hooks/stac"; import { useStore } from "@/store"; import type { StacAssets, StacItemCollection } from "@/types/stac"; +import { loadGeoTIFF } from "@/utils/geotiff"; import { getCogHref, sanitizeBbox } from "@/utils/stac"; import { Checkbox, @@ -109,22 +111,26 @@ export default function Visualization({ const setLayer = useStore((store) => store.setLayer); const setMaplibreLayer = useStore((store) => store.setMaplibreLayer); + const selectedCogHref = useMemo(() => { + if (!selected?.startsWith("asset:")) return undefined; + const asset = assets[selected.slice("asset:".length)]; + return asset && getCogHref(asset); + }, [selected, assets]); + + const { data: selectedCogGeotiff } = useGeoTIFF(selectedCogHref); + useEffect(() => { if (!enabled || !selected) return; if (selected.startsWith("items:")) return; if (selected.startsWith("asset:")) { - const assetKey = selected.slice("asset:".length); - const asset = assets[assetKey]; - if (!asset) return; - const cogHref = getCogHref(asset); - if (!cogHref) return; + if (!selectedCogGeotiff) return; const layerId = "visualization"; setLayer( layerId, new COGLayer({ id: layerId, - geotiff: cogHref, + geotiff: selectedCogGeotiff, }) ); return () => setLayer(layerId, undefined); @@ -163,7 +169,7 @@ export default function Visualization({ }, [ enabled, selected, - assets, + selectedCogGeotiff, tilejsonLink, wmtsLink, setLayer, @@ -265,8 +271,9 @@ function PageLayer({ new MosaicLayer({ id, sources, - getSource: async (source) => source.assets.cog.href, + getSource: async (source) => loadGeoTIFF(source.assets.cog.href), renderSource: (source, { data, signal }) => { + if (!data) return null; const href = source.assets.cog.href; return new COGLayer({ id: `cog-${href}`, diff --git a/src/hooks/stac.ts b/src/hooks/stac.ts index d46d88d..39533b3 100644 --- a/src/hooks/stac.ts +++ b/src/hooks/stac.ts @@ -1,4 +1,5 @@ import { useStore } from "@/store"; +import { loadGeoTIFF } from "@/utils/geotiff"; import { fetchStacGeoparquetItem, fetchStacGeoparquetTable, @@ -64,3 +65,13 @@ export function useStacGeoparquetItem({ fetchStacGeoparquetItem({ href, connection, hivePartitioning, id }), }); } + +export function useGeoTIFF(href: string | undefined) { + return useQuery({ + queryKey: ["geotiff", href], + queryFn: async () => loadGeoTIFF(href!), + enabled: !!href, + staleTime: Infinity, + gcTime: Infinity, + }); +} diff --git a/src/utils/geotiff.ts b/src/utils/geotiff.ts new file mode 100644 index 0000000..2b0f4f9 --- /dev/null +++ b/src/utils/geotiff.ts @@ -0,0 +1,31 @@ +import { SourceCache, SourceChunk } from "@chunkd/middleware"; +import { SourceView } from "@chunkd/source"; +import { SourceHttp } from "@chunkd/source-http"; +import { GeoTIFF } from "@developmentseed/geotiff"; + +/** + * Load a `GeoTIFF` from a URL, priming the underlying HTTP source's metadata + * via a HEAD request before wrapping it in the chunked view. + * + * Mirrors `GeoTIFF.fromUrl`, but the upfront HEAD works around a + * `@chunkd/source-http` regression where `Content-Range` parsing on + * range-fetched responses can leave `metadata.size` unset, breaking COG reads + * (see https://github.com/blacha/chunkd/pull/1666 and + * https://github.com/developmentseed/stac-map/issues/459). `SourceHttp.fetch` + * will not overwrite `metadata` once populated, so the HEAD result wins. + */ +export async function loadGeoTIFF( + href: string, + options: { chunkSize?: number; cacheSize?: number } = {} +): Promise { + const { chunkSize = 32 * 1024, cacheSize = 1024 * 1024 } = options; + const source = new SourceHttp(href, {}); + await source.head(); + const chunk = new SourceChunk({ size: chunkSize }); + const cache = new SourceCache({ size: cacheSize }); + const view = new SourceView(source, [chunk, cache]); + return await GeoTIFF.open({ + dataSource: source, + headerSource: view, + }); +} diff --git a/yarn.lock b/yarn.lock index 54605bd..1bb8e44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -250,14 +250,14 @@ "@pandacss/is-valid-prop" "^1.4.2" csstype "^3.2.3" -"@chunkd/middleware@^11.2.0": +"@chunkd/middleware@^11.2.0", "@chunkd/middleware@^11.3.0": version "11.3.0" resolved "https://registry.yarnpkg.com/@chunkd/middleware/-/middleware-11.3.0.tgz#4d79324a77e4b0c8437f4db3b34ead20aa08346a" integrity sha512-9AzKHP4zX3DUawwJY4wOCCNSPJTmGQc6y1rABsLmoUQ6F5lN73/1u2FkQB0ihNIzzLYz2j86nwvdJSA6GArrtQ== dependencies: "@chunkd/source" "^11.4.0" -"@chunkd/source-http@^11.2.0": +"@chunkd/source-http@^11.2.0", "@chunkd/source-http@^11.4.0": version "11.4.0" resolved "https://registry.yarnpkg.com/@chunkd/source-http/-/source-http-11.4.0.tgz#29e9995ee363f44b430b220bfa70a20af0724d75" integrity sha512-ZkekctSU+7m8SNWxvZNBJA4Uw+eOqhaQq9Sy6gYLMeoZCTWDleID21zjWSbh/4tuSoTN/jiA/U1N9uljWjEY/g==