Skip to content

Commit 74a4ad9

Browse files
committed
WIP
1 parent 9d33095 commit 74a4ad9

13 files changed

Lines changed: 635 additions & 43 deletions

File tree

app/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"surge:teardown": "branch=$(git rev-parse --symbolic-full-name --abbrev-ref HEAD); branch=$(echo $branch | tr ./ -); surge teardown https://ifrc-go-$branch.surge.sh"
4444
},
4545
"dependencies": {
46+
"@graphql-typed-document-node/core": "^3.2.0",
4647
"@ifrc-go/icons": "^2.0.1",
4748
"@ifrc-go/ui": "workspace:^",
4849
"@sentry/react": "^10.0.0",
@@ -57,6 +58,7 @@
5758
"diff-match-patch": "^1.0.5",
5859
"exceljs": "^4.4.0",
5960
"file-saver": "^2.0.5",
61+
"geotiff": "^3.0.5",
6062
"graphql": "^16.14.0",
6163
"html-to-image": "^1.11.13",
6264
"mapbox-gl": "^1.13.3",

app/src/components/Navbar/index.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
sdtUrl,
2424
} from '#config';
2525
import useAuth from '#hooks/domain/useAuth';
26+
import { FIELD_REPORT_STATUS_EARLY_WARNING } from '#utils/constants';
2627

2728
import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
2829
import CountryDropdown from './CountryDropdown';
@@ -313,7 +314,11 @@ function Navbar(props: Props) {
313314
to="fieldReportFormNew"
314315
colorVariant="primary"
315316
styleVariant="action"
316-
state={{ earlyWarning: true }}
317+
state={{
318+
initialValue: {
319+
status: FIELD_REPORT_STATUS_EARLY_WARNING,
320+
},
321+
}}
317322
withoutFullWidth
318323
>
319324
{strings.userMenuCreateEarlyActionFieldReport}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
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;
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { useMemo } from 'react';
2+
import {
3+
ListView,
4+
RadioInput,
5+
Switch,
6+
} from '@ifrc-go/ui';
7+
8+
interface OpacityOption {
9+
key: number;
10+
label: string;
11+
}
12+
13+
function opacityKeySelector(o: OpacityOption) { return o.key; }
14+
function opacityLabelSelector(o: OpacityOption) { return o.label; }
15+
16+
interface Props {
17+
show: boolean;
18+
onShowChange: (value: boolean) => void;
19+
opacity: number;
20+
onOpacityChange: (value: number) => void;
21+
}
22+
23+
function RasterOverlayControl(props: Props) {
24+
const {
25+
show,
26+
onShowChange,
27+
opacity,
28+
onOpacityChange,
29+
} = props;
30+
31+
const opacityOptions = useMemo<OpacityOption[]>(() => ([
32+
{ key: 0.25, label: '25%' },
33+
{ key: 0.5, label: '50%' },
34+
{ key: 0.75, label: '75%' },
35+
{ key: 1, label: '100%' },
36+
]), []);
37+
38+
return (
39+
<ListView
40+
layout="block"
41+
spacing="sm"
42+
withBackground
43+
withPadding
44+
>
45+
<Switch
46+
// FIXME: use strings
47+
label="Show forecast raster"
48+
name="showRaster"
49+
value={show}
50+
onChange={onShowChange}
51+
withInvertedView
52+
/>
53+
{show && (
54+
<RadioInput
55+
// FIXME: use strings
56+
label="Opacity"
57+
name="rasterOpacity"
58+
value={opacity}
59+
options={opacityOptions}
60+
keySelector={opacityKeySelector}
61+
labelSelector={opacityLabelSelector}
62+
onChange={onOpacityChange}
63+
radioListLayout="inline"
64+
spacing="xs"
65+
/>
66+
)}
67+
</ListView>
68+
);
69+
}
70+
71+
export default RasterOverlayControl;

0 commit comments

Comments
 (0)