mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
feat(street-level): one colour per imagery source
As more sources arrive, each one reads the same everywhere it appears: its PROVIDERS chip, coverage lines, overview points, image cones and a single legend swatch. The palette lives in one place, PROVIDER_COLORS in src/layers/streetLevel/policy.js: Mapillary green (#05cb63), Panoramax purple, Google Street View blue. Mapillary drops its age dimming and magenta panoramas: lines and overview points are Mapillary green, cones are green too and 360° images keep their ring shape; the 360°/FLAT filter still separates them, and the selected sequence stays GEV cyan. The provider contract loses `legend`: `colors.coverage` is the source's one colour, and the core composes the legend as one swatch per active source (its name, its colour) followed by Selected. The chip group takes an optional per-chip colour as a `--chip-color` CSS variable, written only when it changes; Street Level chips show it as a swatch dot and, while on, as the chip's border, text and a faint fill. The feature gate checks the legend has one swatch per source plus Selected and that the Mapillary chip carries #05cb63. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
d5da45eacb
commit
0f2cac2ca2
21 files changed
+188
-99
No files matched your search
@@ -261,7 +261,7 @@ export function createStreetLevelLayer({
|
||||
count: stats.count || 0,
|
||||
hint: stats.hint || '',
|
||||
error: stats.error || null,
|
||||
legend: entry.def.legend,
|
||||
color: entry.def.colors.coverage,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ function fakeProvider() {
|
||||
creator: true,
|
||||
follow: true,
|
||||
},
|
||||
legend: [],
|
||||
externalUrl: (id) => `https://example.test/${id}`,
|
||||
create: () => ({
|
||||
status: async () => ({ configured: true }),
|
||||
|
||||
@@ -4,6 +4,17 @@ export const STREET_LEVEL_LAYER_ID = 'street-level';
|
||||
/** Pick id of the viewer position marker; the core owns it, not a provider. */
|
||||
export const POSITION_PICK_ID = 'sl:pos';
|
||||
|
||||
/**
|
||||
* One colour per imagery source, used everywhere that source appears: its
|
||||
* chip, coverage lines, overview points, image cones and legend swatch.
|
||||
* Registered providers take their entry from here; a new source adds one.
|
||||
*/
|
||||
export const PROVIDER_COLORS = Object.freeze({
|
||||
mapillary: '#05cb63',
|
||||
panoramax: '#a66bff',
|
||||
'google-street-view': '#4285f4',
|
||||
});
|
||||
|
||||
/** Colours every provider shares: the selection highlight and the marker. */
|
||||
export const COLORS = Object.freeze({
|
||||
selected: '#00d4ff',
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
COVERAGE_MOVE_DEBOUNCE_MS,
|
||||
COVERAGE_OVERVIEW_MAX_TILES,
|
||||
COVERAGE_OVERVIEW_POINT_PX,
|
||||
COVERAGE_RECENT_DAYS,
|
||||
PICK_PREFIX,
|
||||
} from './policy.js';
|
||||
|
||||
@@ -34,15 +33,11 @@ const PER_TILE_SEQUENCE_CAP = Math.floor(
|
||||
COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES,
|
||||
);
|
||||
|
||||
/** Colour for a sequence: brand green, dimmer with age, magenta for panoramas. */
|
||||
function sequenceColor(sequence, { selected = false, now = Date.now() } = {}) {
|
||||
if (selected) return Cesium.Color.fromCssColorString(COLORS.selected);
|
||||
if (sequence.isPano)
|
||||
return Cesium.Color.fromCssColorString(COLORS.pano).withAlpha(0.9);
|
||||
const ageDays = (now - (sequence.capturedAt || 0)) / 86_400_000;
|
||||
return ageDays <= COVERAGE_RECENT_DAYS
|
||||
? Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92)
|
||||
: Cesium.Color.fromCssColorString(COLORS.coverageOld).withAlpha(0.7);
|
||||
/** Colour for a sequence: Mapillary green, GEV cyan while selected. */
|
||||
function sequenceColor({ selected = false } = {}) {
|
||||
return selected
|
||||
? Cesium.Color.fromCssColorString(COLORS.selected)
|
||||
: Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,14 +77,13 @@ export function createCoverage({ state, source }) {
|
||||
);
|
||||
}
|
||||
|
||||
function sequenceInstance(sequence, geometry, now) {
|
||||
function sequenceInstance(sequence, geometry) {
|
||||
return new Cesium.GeometryInstance({
|
||||
geometry,
|
||||
id: `${PICK_PREFIX.sequence}${sequence.id}`,
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
sequenceColor(sequence, {
|
||||
now,
|
||||
sequenceColor({
|
||||
selected: sequence.id === state.sequence.selectedId,
|
||||
}),
|
||||
),
|
||||
@@ -103,7 +97,6 @@ export function createCoverage({ state, source }) {
|
||||
* bare-earth heights for every sequence whose heights are cached.
|
||||
*/
|
||||
function buildSequencePrimitives(sequences) {
|
||||
const now = Date.now();
|
||||
const ground = terrainMode() ? state.context.groundCaster : null;
|
||||
const draped = [];
|
||||
const cast = [];
|
||||
@@ -129,7 +122,6 @@ export function createCoverage({ state, source }) {
|
||||
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
}),
|
||||
now,
|
||||
),
|
||||
);
|
||||
else
|
||||
@@ -140,7 +132,6 @@ export function createCoverage({ state, source }) {
|
||||
positions,
|
||||
width: COVERAGE_LINE_WIDTH_PX,
|
||||
}),
|
||||
now,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -234,13 +225,12 @@ export function createCoverage({ state, source }) {
|
||||
const green = Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(
|
||||
0.85,
|
||||
);
|
||||
const pink = Cesium.Color.fromCssColorString(COLORS.pano).withAlpha(0.9);
|
||||
let count = 0;
|
||||
for (const point of points) {
|
||||
if (!passesImageryFilter(point, filter())) continue;
|
||||
collection.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat),
|
||||
color: point.isPano ? pink : green,
|
||||
color: green,
|
||||
pixelSize: COVERAGE_OVERVIEW_POINT_PX,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
@@ -519,7 +509,7 @@ export function createCoverage({ state, source }) {
|
||||
primitive.getGeometryInstanceAttributes(instanceId);
|
||||
if (attributes)
|
||||
attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
sequenceColor(sequence, { selected }),
|
||||
sequenceColor({ selected }),
|
||||
attributes.color,
|
||||
);
|
||||
} catch {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
MAPILLARY_CREDIT_HTML,
|
||||
MAPILLARY_KEY_ID,
|
||||
MAPILLARY_LABEL,
|
||||
MAPILLARY_LEGEND,
|
||||
MAPILLARY_NAME,
|
||||
MAPILLARY_PROVIDER_ID,
|
||||
NEAREST_LIMIT,
|
||||
@@ -92,7 +91,6 @@ export function createMapillaryProvider({ source }) {
|
||||
creator: true,
|
||||
follow: true,
|
||||
}),
|
||||
legend: MAPILLARY_LEGEND,
|
||||
externalUrl: mapillaryImageUrl,
|
||||
|
||||
create(context) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { COLORS as SHARED_COLORS } from '../../policy.js';
|
||||
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
|
||||
|
||||
/** Identity and tuning for the Mapillary street-level provider. */
|
||||
export const MAPILLARY_PROVIDER_ID = 'mapillary';
|
||||
@@ -14,13 +14,13 @@ export const PICK_PREFIX = Object.freeze({
|
||||
image: 'mly:img:',
|
||||
});
|
||||
|
||||
/** Brand green for coverage; panoramas get a distinct hue; selection is GEV cyan. */
|
||||
/**
|
||||
* Mapillary draws in one colour, its brand green, for lines, overview points
|
||||
* and cones alike (360° cones keep their ring shape); selection is GEV cyan.
|
||||
*/
|
||||
export const COLORS = Object.freeze({
|
||||
coverage: '#05cb63',
|
||||
coverageOld: '#2e7d5b',
|
||||
pano: '#ff4fd8',
|
||||
coverage: PROVIDER_COLORS.mapillary,
|
||||
selected: SHARED_COLORS.selected,
|
||||
image: '#e8eaed',
|
||||
});
|
||||
|
||||
/** Camera-driven coverage refresh. */
|
||||
@@ -31,8 +31,6 @@ export const COVERAGE_OVERVIEW_MAX_TILES = 16;
|
||||
export const COVERAGE_OVERVIEW_POINT_PX = 2.5;
|
||||
export const COVERAGE_MAX_SEQUENCES = 6000;
|
||||
export const COVERAGE_LINE_WIDTH_PX = 2.5;
|
||||
/** Sequences newer than this many days draw at full brightness. */
|
||||
export const COVERAGE_RECENT_DAYS = 730;
|
||||
|
||||
/** Per-sequence image cones after a sequence is selected. */
|
||||
export const SEQUENCE_IMAGES_LIMIT = 2000;
|
||||
@@ -50,17 +48,6 @@ export const NEAREST_LIMIT = 8;
|
||||
export const MAPILLARY_CREDIT_HTML =
|
||||
'Street Level: imagery © <a href="https://www.mapillary.com" target="_blank" rel="noopener">Mapillary</a> contributors, CC BY-SA 4.0';
|
||||
|
||||
/** Coverage colour key, in the order the panel lists it. */
|
||||
export const MAPILLARY_LEGEND = Object.freeze([
|
||||
Object.freeze({
|
||||
key: 'recent',
|
||||
label: `Recent (≤${Math.round(COVERAGE_RECENT_DAYS / 365)} yr)`,
|
||||
color: COLORS.coverage,
|
||||
}),
|
||||
Object.freeze({ key: 'older', label: 'Older', color: COLORS.coverageOld }),
|
||||
Object.freeze({ key: 'pano', label: '360°', color: COLORS.pano }),
|
||||
]);
|
||||
|
||||
/** Deep link to an image on mapillary.com, as the web app shares them. */
|
||||
export function mapillaryImageUrl(imageId) {
|
||||
const id = String(imageId || '').trim();
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createMapillaryProvider, mapillaryImageUrl } from './index.js';
|
||||
import { validateProviders } from '../../registry.js';
|
||||
import { PROVIDER_COLORS } from '../../policy.js';
|
||||
|
||||
function fakeSource({ nearest = [] } = {}) {
|
||||
return {
|
||||
@@ -41,10 +42,10 @@ test('the definition satisfies the Street Level provider contract', () => {
|
||||
assert.equal(def.pickPrefix, 'mly:');
|
||||
assert.equal(def.capabilities.coverage, 'tiles');
|
||||
assert.match(def.credit.html, /CC BY-SA 4\.0/);
|
||||
assert.deepEqual(
|
||||
def.legend.map((entry) => entry.key),
|
||||
['recent', 'older', 'pano'],
|
||||
);
|
||||
// One colour per source: Mapillary is green everywhere it draws.
|
||||
assert.deepEqual(Object.keys(def.colors).sort(), ['coverage', 'selected']);
|
||||
assert.equal(def.colors.coverage, PROVIDER_COLORS.mapillary);
|
||||
assert.equal(PROVIDER_COLORS.mapillary, '#05cb63');
|
||||
assert.throws(() => createMapillaryProvider({ source: {} }), /source/);
|
||||
});
|
||||
|
||||
|
||||
@@ -103,8 +103,13 @@ export function createSequences({ state, source, parts }) {
|
||||
if (!collection) return;
|
||||
collection.removeAll();
|
||||
const heights = coneHeights(images);
|
||||
const cone = imageConeGlyph({ size: 32, color: COLORS.image });
|
||||
const ring = imageConeGlyph({ size: 32, color: COLORS.pano, pano: true });
|
||||
// One colour per source; 360° images keep the ring shape.
|
||||
const cone = imageConeGlyph({ size: 32, color: COLORS.coverage });
|
||||
const ring = imageConeGlyph({
|
||||
size: 32,
|
||||
color: COLORS.coverage,
|
||||
pano: true,
|
||||
});
|
||||
images.forEach((image, index) => {
|
||||
if (!passesImageryFilter(image, state.filter)) return;
|
||||
const height = heights?.[index] ?? null;
|
||||
|
||||
@@ -7,10 +7,9 @@
|
||||
* @property {string} label Chip text and link label, e.g. 'MAPILLARY'.
|
||||
* @property {string|null} requiresKeyId Key-setup id the provider needs, or null when keyless.
|
||||
* @property {string} pickPrefix Every primitive id the provider creates starts with it.
|
||||
* @property {{coverage: string, coverageOld?: string, pano?: string}} colors
|
||||
* @property {{coverage: string}} colors The source's one colour (policy.js PROVIDER_COLORS): chip, lines, points, cones and legend.
|
||||
* @property {{key: string, html: string}} credit On-globe attribution while the provider is active.
|
||||
* @property {{coverage: 'tiles'|'bbox'|'none', sequences: boolean, pano: boolean, capturedAt: boolean, creator: boolean, follow: boolean}} capabilities
|
||||
* @property {Array<{key: string, label: string, color: string}>} legend Coverage colour key.
|
||||
* @property {(imageId: string) => string} externalUrl Deep link to the image on the provider's site.
|
||||
* @property {(context: ProviderContext) => ProviderInstance} create
|
||||
*
|
||||
@@ -80,7 +79,6 @@ const REQUIRED = Object.freeze([
|
||||
'colors',
|
||||
'credit',
|
||||
'capabilities',
|
||||
'legend',
|
||||
'externalUrl',
|
||||
'create',
|
||||
]);
|
||||
@@ -127,9 +125,12 @@ export function validateProviders(providers) {
|
||||
);
|
||||
if (!provider.credit?.key || !provider.credit?.html)
|
||||
throw new TypeError(`Street Level provider ${label} needs a credit`);
|
||||
if (!Array.isArray(provider.legend))
|
||||
if (
|
||||
typeof provider.colors?.coverage !== 'string' ||
|
||||
!provider.colors.coverage
|
||||
)
|
||||
throw new TypeError(
|
||||
`Street Level provider ${label}: legend is not a list`,
|
||||
`Street Level provider ${label} needs a coverage colour`,
|
||||
);
|
||||
ids.add(provider.id);
|
||||
prefixes.push(provider.pickPrefix);
|
||||
|
||||
@@ -11,7 +11,6 @@ const def = (overrides = {}) => ({
|
||||
colors: { coverage: '#05cb63' },
|
||||
credit: { key: 'mapillary', html: 'Mapillary' },
|
||||
capabilities: { coverage: 'tiles', sequences: true },
|
||||
legend: [],
|
||||
externalUrl: (id) => `https://example.test/${id}`,
|
||||
create: () => ({}),
|
||||
...overrides,
|
||||
@@ -44,7 +43,6 @@ test('every required field is checked by name', () => {
|
||||
'colors',
|
||||
'credit',
|
||||
'capabilities',
|
||||
'legend',
|
||||
'externalUrl',
|
||||
'create',
|
||||
])
|
||||
@@ -58,7 +56,10 @@ test('every required field is checked by name', () => {
|
||||
() => validateProviders([def({ credit: { key: 'x' } })]),
|
||||
/credit/,
|
||||
);
|
||||
assert.throws(() => validateProviders([def({ legend: 'no' })]), /legend/);
|
||||
assert.throws(
|
||||
() => validateProviders([def({ colors: { pano: '#f0f' } })]),
|
||||
/coverage colour/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateProviders([def({ pickPrefix: '' })]),
|
||||
/pick prefix/,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { COLORS } from './policy.js';
|
||||
* @property {number} count
|
||||
* @property {string} hint
|
||||
* @property {string|null} error
|
||||
* @property {Array<{key: string, label: string, color: string}>} legend
|
||||
* @property {string} color The source's one colour.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -37,9 +37,9 @@ export function summarizeCoverage(providers) {
|
||||
* Compose the snapshot the panel renders from the core state and one
|
||||
* snapshot per registered provider. Pure, so the merge rules are testable:
|
||||
* counts add up across active providers, the layer is key-gated only when
|
||||
* every switched-on provider lacks its key, and the legend lists each active
|
||||
* provider's colours (prefixed by name once more than one is registered)
|
||||
* followed by the shared selection colour.
|
||||
* every switched-on provider lacks its key, and the legend lists one swatch
|
||||
* per active source, in its colour and under its name, followed by the
|
||||
* shared selection colour.
|
||||
* @param {{enabled: boolean, filter: object, providers: Array<ProviderSnapshot>, street: object, sequence: object}} input
|
||||
*/
|
||||
export function composeUIState({
|
||||
@@ -52,24 +52,18 @@ export function composeUIState({
|
||||
}) {
|
||||
const active = providers.filter((p) => p.on);
|
||||
const { keyRequired, ...coverage } = summarizeCoverage(providers);
|
||||
const legend = [];
|
||||
for (const provider of active)
|
||||
for (const entry of provider.legend)
|
||||
legend.push({
|
||||
key: `${provider.id}:${entry.key}`,
|
||||
label:
|
||||
providers.length > 1
|
||||
? `${provider.name} ${entry.label}`
|
||||
: entry.label,
|
||||
color: entry.color,
|
||||
});
|
||||
const legend = active.map((provider) => ({
|
||||
key: provider.id,
|
||||
label: provider.name,
|
||||
color: provider.color,
|
||||
}));
|
||||
if (active.length)
|
||||
legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected });
|
||||
return {
|
||||
enabled,
|
||||
keyRequired,
|
||||
filter: { ...filter },
|
||||
providers: providers.map((p) => ({ ...p, legend: [...p.legend] })),
|
||||
providers: providers.map((p) => ({ ...p })),
|
||||
coverage,
|
||||
legend,
|
||||
sequence: { ...sequence },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeUIState, summarizeCoverage } from './uiState.js';
|
||||
import { COLORS } from './policy.js';
|
||||
|
||||
const provider = (overrides = {}) => ({
|
||||
id: 'mapillary',
|
||||
@@ -14,7 +15,7 @@ const provider = (overrides = {}) => ({
|
||||
count: 10,
|
||||
hint: '',
|
||||
error: null,
|
||||
legend: [{ key: 'recent', label: 'Recent', color: '#0f0' }],
|
||||
color: '#05cb63',
|
||||
...overrides,
|
||||
});
|
||||
const base = {
|
||||
@@ -58,30 +59,37 @@ test('the layer is key-gated only when every switched-on provider lacks its key'
|
||||
assert.equal(offOnly.keyRequired, false, 'nothing switched on to gate');
|
||||
});
|
||||
|
||||
test('the legend lists active providers, prefixed once more than one is registered', () => {
|
||||
test('the legend is one swatch per active source, in its colour, then Selected', () => {
|
||||
const single = composeUIState({ ...base, providers: [provider()] });
|
||||
assert.deepEqual(
|
||||
single.legend.map((entry) => [entry.key, entry.label]),
|
||||
[
|
||||
['mapillary:recent', 'Recent'],
|
||||
['selected', 'Selected'],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(single.legend, [
|
||||
{ key: 'mapillary', label: 'Mapillary', color: '#05cb63' },
|
||||
{ key: 'selected', label: 'Selected', color: COLORS.selected },
|
||||
]);
|
||||
const two = composeUIState({
|
||||
...base,
|
||||
providers: [
|
||||
provider(),
|
||||
provider({
|
||||
id: 'panoramax',
|
||||
name: 'Panoramax',
|
||||
on: false,
|
||||
legend: [{ key: 'coverage', label: 'Coverage', color: '#00f' }],
|
||||
}),
|
||||
provider({ id: 'panoramax', name: 'Panoramax', color: '#a66bff' }),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
two.legend.map((entry) => entry.label),
|
||||
['Mapillary Recent', 'Selected'],
|
||||
two.legend.map((entry) => [entry.label, entry.color]),
|
||||
[
|
||||
['Mapillary', '#05cb63'],
|
||||
['Panoramax', '#a66bff'],
|
||||
['Selected', COLORS.selected],
|
||||
],
|
||||
);
|
||||
const oneOff = composeUIState({
|
||||
...base,
|
||||
providers: [
|
||||
provider(),
|
||||
provider({ id: 'panoramax', name: 'Panoramax', on: false }),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
oneOff.legend.map((entry) => entry.key),
|
||||
['mapillary', 'selected'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
composeUIState({ ...base, providers: [provider({ on: false })] }).legend,
|
||||
|
||||
Reference in new issue
Block a user