|
| 1 | +import { |
| 2 | + useEffect, |
| 3 | + useMemo, |
| 4 | + useState, |
| 5 | +} from 'react'; |
| 6 | +import { |
| 7 | + MapLayer, |
| 8 | + MapSource, |
| 9 | +} from '@togglecorp/re-map'; |
| 10 | +import { fromUrl } from 'geotiff'; |
| 11 | +import type { RasterLayer } from 'mapbox-gl'; |
| 12 | + |
| 13 | +import { |
| 14 | + COLOR_LIGHT_BLUE, |
| 15 | + COLOR_PRIMARY_RED, |
| 16 | +} from '#utils/constants'; |
| 17 | + |
| 18 | +interface Decoded { |
| 19 | + dataUrl: string; |
| 20 | + coordinates: [ |
| 21 | + [number, number], |
| 22 | + [number, number], |
| 23 | + [number, number], |
| 24 | + [number, number], |
| 25 | + ]; |
| 26 | +} |
| 27 | + |
| 28 | +const TARGET_OVERVIEW_PIXELS = 512 * 512; |
| 29 | +const UPSCALE = 4; |
| 30 | + |
| 31 | +function hexToRgb(hex: string): [number, number, number] { |
| 32 | + const cleaned = hex.replace('#', ''); |
| 33 | + const r = parseInt(cleaned.slice(0, 2), 16); |
| 34 | + const g = parseInt(cleaned.slice(2, 4), 16); |
| 35 | + const b = parseInt(cleaned.slice(4, 6), 16); |
| 36 | + return [r, g, b]; |
| 37 | +} |
| 38 | + |
| 39 | +const [START_R, START_G, START_B] = hexToRgb(COLOR_LIGHT_BLUE); |
| 40 | +const [END_R, END_G, END_B] = hexToRgb(COLOR_PRIMARY_RED); |
| 41 | + |
| 42 | +// Linear interpolation between the two endpoint colors. Alpha floor needs to |
| 43 | +// be high enough that the pale start color remains visible against a light |
| 44 | +// basemap, while still letting raster-opacity dial the whole thing back if |
| 45 | +// it's too loud. |
| 46 | +function valueToRgba(t: number): [number, number, number, number] { |
| 47 | + const r = Math.round(START_R + (END_R - START_R) * t); |
| 48 | + const g = Math.round(START_G + (END_G - START_G) * t); |
| 49 | + const b = Math.round(START_B + (END_B - START_B) * t); |
| 50 | + const a = Math.round(110 + 145 * t); |
| 51 | + return [r, g, b, a]; |
| 52 | +} |
| 53 | + |
| 54 | +interface Props { |
| 55 | + cogUrl: string; |
| 56 | + opacity: number; |
| 57 | +} |
| 58 | + |
| 59 | +function JbaCogRasterLayer(props: Props) { |
| 60 | + const { cogUrl, opacity } = props; |
| 61 | + |
| 62 | + const [decoded, setDecoded] = useState<Decoded | undefined>(); |
| 63 | + |
| 64 | + useEffect(() => { |
| 65 | + let cancelled = false; |
| 66 | + // eslint-disable-next-line react-hooks/set-state-in-effect |
| 67 | + setDecoded(undefined); |
| 68 | + |
| 69 | + (async () => { |
| 70 | + try { |
| 71 | + const tiff = await fromUrl(cogUrl); |
| 72 | + const imageCount = await tiff.getImageCount(); |
| 73 | + const fullImage = await tiff.getImage(0); |
| 74 | + const bbox = fullImage.getBoundingBox(); |
| 75 | + const west = bbox[0] ?? 0; |
| 76 | + const south = bbox[1] ?? 0; |
| 77 | + const east = bbox[2] ?? 0; |
| 78 | + const north = bbox[3] ?? 0; |
| 79 | + |
| 80 | + // Pick the overview level closest to TARGET_OVERVIEW_PIXELS. |
| 81 | + let renderImage = fullImage; |
| 82 | + let bestDiff = Math.abs( |
| 83 | + fullImage.getWidth() * fullImage.getHeight() - TARGET_OVERVIEW_PIXELS, |
| 84 | + ); |
| 85 | + for (let i = 1; i < imageCount; i += 1) { |
| 86 | + // eslint-disable-next-line no-await-in-loop |
| 87 | + const img = await tiff.getImage(i); |
| 88 | + const pixels = img.getWidth() * img.getHeight(); |
| 89 | + const diff = Math.abs(pixels - TARGET_OVERVIEW_PIXELS); |
| 90 | + if (diff < bestDiff) { |
| 91 | + bestDiff = diff; |
| 92 | + renderImage = img; |
| 93 | + } |
| 94 | + } |
| 95 | + |
| 96 | + const rasters = await renderImage.readRasters({ samples: [0] }); |
| 97 | + const band = rasters[0] as unknown as ArrayLike<number>; |
| 98 | + const width = renderImage.getWidth(); |
| 99 | + const height = renderImage.getHeight(); |
| 100 | + |
| 101 | + // Per-image min/max over non-zero values for normalisation. |
| 102 | + let min = Infinity; |
| 103 | + let max = -Infinity; |
| 104 | + for (let i = 0; i < band.length; i += 1) { |
| 105 | + const v = band[i] ?? 0; |
| 106 | + if (v > 0) { |
| 107 | + if (v < min) min = v; |
| 108 | + if (v > max) max = v; |
| 109 | + } |
| 110 | + } |
| 111 | + const range = max - min || 1; |
| 112 | + |
| 113 | + const raw = document.createElement('canvas'); |
| 114 | + raw.width = width; |
| 115 | + raw.height = height; |
| 116 | + const rawCtx = raw.getContext('2d'); |
| 117 | + if (!rawCtx) { |
| 118 | + return; |
| 119 | + } |
| 120 | + const imgData = rawCtx.createImageData(width, height); |
| 121 | + for (let i = 0; i < band.length; i += 1) { |
| 122 | + const v = band[i] ?? 0; |
| 123 | + const idx = i * 4; |
| 124 | + if (v <= 0) { |
| 125 | + imgData.data[idx + 3] = 0; |
| 126 | + } else { |
| 127 | + const t = Math.min((v - min) / range, 1); |
| 128 | + const [r, g, b, a] = valueToRgba(t); |
| 129 | + imgData.data[idx] = r; |
| 130 | + imgData.data[idx + 1] = g; |
| 131 | + imgData.data[idx + 2] = b; |
| 132 | + imgData.data[idx + 3] = a; |
| 133 | + } |
| 134 | + } |
| 135 | + rawCtx.putImageData(imgData, 0, 0); |
| 136 | + |
| 137 | + // Upscale with smoothing to soften block edges at display size. |
| 138 | + const canvas = document.createElement('canvas'); |
| 139 | + canvas.width = width * UPSCALE; |
| 140 | + canvas.height = height * UPSCALE; |
| 141 | + const ctx = canvas.getContext('2d'); |
| 142 | + if (!ctx) { |
| 143 | + return; |
| 144 | + } |
| 145 | + ctx.imageSmoothingEnabled = true; |
| 146 | + ctx.imageSmoothingQuality = 'high'; |
| 147 | + ctx.drawImage(raw, 0, 0, canvas.width, canvas.height); |
| 148 | + |
| 149 | + if (cancelled) { |
| 150 | + return; |
| 151 | + } |
| 152 | + |
| 153 | + setDecoded({ |
| 154 | + dataUrl: canvas.toDataURL(), |
| 155 | + coordinates: [ |
| 156 | + [west, north], |
| 157 | + [east, north], |
| 158 | + [east, south], |
| 159 | + [west, south], |
| 160 | + ], |
| 161 | + }); |
| 162 | + } catch (err) { |
| 163 | + if (!cancelled) { |
| 164 | + // eslint-disable-next-line no-console |
| 165 | + console.warn(`[JbaCogRasterLayer] failed to decode ${cogUrl}:`, err); |
| 166 | + } |
| 167 | + } |
| 168 | + })(); |
| 169 | + |
| 170 | + return () => { |
| 171 | + cancelled = true; |
| 172 | + }; |
| 173 | + }, [cogUrl]); |
| 174 | + |
| 175 | + const sourceOptions = useMemo(() => { |
| 176 | + if (!decoded) { |
| 177 | + return undefined; |
| 178 | + } |
| 179 | + return { |
| 180 | + type: 'image' as const, |
| 181 | + url: decoded.dataUrl, |
| 182 | + coordinates: decoded.coordinates, |
| 183 | + }; |
| 184 | + }, [decoded]); |
| 185 | + |
| 186 | + const rasterLayer = useMemo<Omit<RasterLayer, 'id'>>(() => ({ |
| 187 | + type: 'raster', |
| 188 | + paint: { |
| 189 | + 'raster-opacity': opacity, |
| 190 | + 'raster-resampling': 'nearest', |
| 191 | + }, |
| 192 | + layout: { visibility: 'visible' }, |
| 193 | + }), [opacity]); |
| 194 | + |
| 195 | + if (!sourceOptions) { |
| 196 | + return null; |
| 197 | + } |
| 198 | + |
| 199 | + return ( |
| 200 | + <MapSource |
| 201 | + sourceKey="jba-cog" |
| 202 | + sourceOptions={sourceOptions} |
| 203 | + > |
| 204 | + <MapLayer |
| 205 | + layerKey="jba-cog-layer" |
| 206 | + layerOptions={rasterLayer} |
| 207 | + /> |
| 208 | + </MapSource> |
| 209 | + ); |
| 210 | +} |
| 211 | + |
| 212 | +export default JbaCogRasterLayer; |
0 commit comments