mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge upstream/main into pr/mapillary-layer
Twenty layers now (weather, wind, cyclones and recent imagery landed upstream); Street Level sits after Recent Imagery in the right rail and the Cameras group, and the rail CSS names it beside the other panels. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
commit
817716f0c1
234 files changed
+45441
-1430
No files matched your search
+203
-4
@@ -168,7 +168,14 @@ function stringOption(key, token, defaultValue) {
|
||||
});
|
||||
}
|
||||
|
||||
function enumOption(key, token, defaultValue, values, codes) {
|
||||
function enumOption(
|
||||
key,
|
||||
token,
|
||||
defaultValue,
|
||||
values,
|
||||
codes,
|
||||
{ absentValue = defaultValue } = {},
|
||||
) {
|
||||
const reverse = Object.fromEntries(
|
||||
Object.entries(codes).map(([name, code]) => [code, name]),
|
||||
);
|
||||
@@ -176,6 +183,7 @@ function enumOption(key, token, defaultValue, values, codes) {
|
||||
key,
|
||||
token,
|
||||
defaultValue,
|
||||
absentValue,
|
||||
normalize: (value) => normalizeEnum(values, value),
|
||||
encode: (value) => codes[value],
|
||||
decode: (value) => reverse[value] || null,
|
||||
@@ -204,7 +212,131 @@ function integerOption(key, token, defaultValue) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A share-link-only option: the whole-state codec carries it, but the stored
|
||||
* local blob always holds its default.
|
||||
*/
|
||||
function shareOnlyOption(spec) {
|
||||
return Object.freeze({ ...spec, shareOnly: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* A signed integer in `[min, max]`, rejected (never clamped) outside it: a
|
||||
* clamped box edge would be a different box, not a shorter spelling.
|
||||
*/
|
||||
function boundedIntegerOption(key, token, defaultValue, { min, max }) {
|
||||
const parse = (value) => {
|
||||
const number =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: /^-?\d{1,9}$/.test(String(value).trim())
|
||||
? Number(String(value).trim())
|
||||
: NaN;
|
||||
return Number.isInteger(number) && number >= min && number <= max
|
||||
? number
|
||||
: null;
|
||||
};
|
||||
return Object.freeze({
|
||||
key,
|
||||
token,
|
||||
defaultValue,
|
||||
normalize: parse,
|
||||
encode: (value) => String(value),
|
||||
decode: parse,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent Imagery day: runtime key `S30:2026-09-18`, URL form `S20260918`.
|
||||
* The calendar is checked both ways, and "today" is never consulted, so a
|
||||
* link decodes the same whenever it is opened.
|
||||
*/
|
||||
const IMAGERY_LETTERS = Object.freeze({ S30: 'S', L30: 'L', VIIRS: 'V' });
|
||||
const IMAGERY_PRODUCTS = Object.freeze({ S: 'S30', L: 'L30', V: 'VIIRS' });
|
||||
|
||||
function imageryKey(product, year, month, day) {
|
||||
const time = Date.UTC(Number(year), Number(month) - 1, Number(day));
|
||||
const iso = Number.isFinite(time)
|
||||
? new Date(time).toISOString().slice(0, 10)
|
||||
: '';
|
||||
return product && iso === `${year}-${month}-${day}`
|
||||
? `${product}:${iso}`
|
||||
: null;
|
||||
}
|
||||
|
||||
function imageryPinOption(key, token) {
|
||||
return Object.freeze({
|
||||
key,
|
||||
token,
|
||||
defaultValue: null,
|
||||
normalize: (value) => {
|
||||
const match = /^(S30|L30|VIIRS):(\d{4})-(\d{2})-(\d{2})$/.exec(
|
||||
typeof value === 'string' ? value.trim() : '',
|
||||
);
|
||||
return match ? imageryKey(match[1], match[2], match[3], match[4]) : null;
|
||||
},
|
||||
encode: (value) => {
|
||||
const [product, day] = value.split(':');
|
||||
return `${IMAGERY_LETTERS[product]}${day.replaceAll('-', '')}`;
|
||||
},
|
||||
decode: (value) => {
|
||||
const match = /^([SLV])(\d{4})(\d{2})(\d{2})$/.exec(
|
||||
typeof value === 'string' ? value : '',
|
||||
);
|
||||
return match
|
||||
? imageryKey(IMAGERY_PRODUCTS[match[1]], match[2], match[3], match[4])
|
||||
: null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const OPTION_GROUPS = Object.freeze({
|
||||
'weather-lightning': Object.freeze([
|
||||
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
|
||||
light: 'l',
|
||||
strong: 's',
|
||||
}),
|
||||
]),
|
||||
'weather-radar': Object.freeze([
|
||||
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
|
||||
light: 'l',
|
||||
strong: 's',
|
||||
}),
|
||||
]),
|
||||
'weather-satellite': Object.freeze([
|
||||
enumOption('infrared', 'i', 'filtered', ['filtered', 'full'], {
|
||||
filtered: 'f',
|
||||
full: 'a',
|
||||
}),
|
||||
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
|
||||
light: 'l',
|
||||
strong: 's',
|
||||
}),
|
||||
enumOption(
|
||||
'product',
|
||||
'p',
|
||||
'clouds-regional',
|
||||
['clouds', 'clouds-regional'],
|
||||
{ clouds: 'g', 'clouds-regional': 'r' },
|
||||
),
|
||||
]),
|
||||
wind: Object.freeze([
|
||||
enumOption('model', 'm', 'gfs', ['gfs', 'ifs'], { gfs: 'g', ifs: 'i' }),
|
||||
enumOption(
|
||||
'overlay',
|
||||
'o',
|
||||
'none',
|
||||
['none', 'speed', 'temperature', 'pressure'],
|
||||
{ none: 'n', speed: 's', temperature: 't', pressure: 'p' },
|
||||
{ absentValue: 'speed' },
|
||||
),
|
||||
enumOption('units', 'u', 'km/h', ['km/h', 'm/s', 'mph'], {
|
||||
'km/h': 'k',
|
||||
'm/s': 'm',
|
||||
mph: 'i',
|
||||
}),
|
||||
booleanOption('paused', 'p', false),
|
||||
]),
|
||||
flights: Object.freeze([
|
||||
// Owner directive 2026-08-22: the fleet's 3D models are DEFAULT-ON in
|
||||
// PROXIMITY mode. Proximity is itself the altitude/count gate — models only
|
||||
@@ -249,6 +381,23 @@ const OPTION_GROUPS = Object.freeze({
|
||||
booleanOption('showProjection', 'p', true),
|
||||
booleanOption('autoHop', 'a', false),
|
||||
]),
|
||||
'recent-imagery': Object.freeze([
|
||||
// Box edges in degrees × 100000; latitudes stop at the Web-Mercator limit.
|
||||
boundedIntegerOption('west', 'w', null, { min: -18000000, max: 18000000 }),
|
||||
boundedIntegerOption('south', 's', null, { min: -8505110, max: 8505110 }),
|
||||
boundedIntegerOption('east', 'e', null, { min: -18000000, max: 18000000 }),
|
||||
boundedIntegerOption('north', 'n', null, { min: -8505110, max: 8505110 }),
|
||||
imageryPinOption('a', 'a'),
|
||||
imageryPinOption('b', 'b'),
|
||||
// 0 one image, 1 image against the basemap, 2 two images A / B.
|
||||
boundedIntegerOption('mode', 'm', 0, { min: 0, max: 2 }),
|
||||
// The swipe position is one comparison's framing, not a preference: a
|
||||
// stored split resurfaced in the next session's first comparison.
|
||||
shareOnlyOption(
|
||||
boundedIntegerOption('split', 'p', 50, { min: 0, max: 100 }),
|
||||
),
|
||||
booleanOption('viirs', 'v', false),
|
||||
]),
|
||||
radio: Object.freeze([
|
||||
Object.freeze({
|
||||
key: 'filter',
|
||||
@@ -367,6 +516,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'radio',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'recent-imagery',
|
||||
token: '1',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'recent-imagery',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'rocket-launches',
|
||||
token: 'x',
|
||||
@@ -385,6 +540,35 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
}),
|
||||
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'transit', token: 'j', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'weather-cyclones',
|
||||
token: 'y',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'weather-lightning',
|
||||
token: 'l',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'weather-lightning',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'weather-radar',
|
||||
token: 'v',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'weather-radar',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'weather-satellite',
|
||||
token: 'o',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'weather-satellite',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'wind',
|
||||
token: 'k',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'wind',
|
||||
}),
|
||||
]);
|
||||
|
||||
export const REGISTERED_LAYER_IDS = Object.freeze(
|
||||
@@ -617,13 +801,28 @@ export function decodeLayerStateParams(params) {
|
||||
return normalizeLayerState({ enabledLayerIds, options: rawOptions });
|
||||
}
|
||||
|
||||
/** Stable local-storage representation (full IDs for debuggability). */
|
||||
/** Options with share-link-only values reset to their defaults. */
|
||||
function withoutShareOnlyOptions(options) {
|
||||
const out = {};
|
||||
for (const ownerId of OPTION_OWNER_IDS) {
|
||||
out[ownerId] = { ...(options?.[ownerId] || {}) };
|
||||
for (const spec of optionSpecs(ownerId)) {
|
||||
if (spec.shareOnly) out[ownerId][spec.key] = spec.defaultValue;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable local-storage representation (full IDs for debuggability), with
|
||||
* share-link-only options at their defaults.
|
||||
*/
|
||||
export function serializeStoredLayerState(state) {
|
||||
const normalized = normalizeLayerState(state);
|
||||
return JSON.stringify({
|
||||
v: LAYER_STATE_VERSION,
|
||||
l: normalized.enabledLayerIds,
|
||||
o: normalized.options,
|
||||
o: withoutShareOnlyOptions(normalized.options),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -635,7 +834,7 @@ export function parseStoredLayerState(raw) {
|
||||
return null;
|
||||
return normalizeLayerState({
|
||||
enabledLayerIds: parsed.l,
|
||||
options: parsed.o,
|
||||
options: withoutShareOnlyOptions(parsed.o),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
Reference in new issue
Block a user