mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24: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
@@ -8,6 +8,8 @@
|
||||
|
||||
- Street Level: a provider-neutral street-level imagery layer modelled on the iD editor's photo overlay. One panel in the right rail carries a chip per imagery provider, shared 360°/flat and captured-since filters, coverage drawn on the globe from orbit down to a single street, image cones per sequence, and one embedded viewer whose adapter follows the provider of the open image; each active provider credits its imagery on the globe, and share links carry the provider switches and the filter. Mapillary is the first provider (free client token; CC BY-SA 4.0 imagery); Google Street View, KartaView and Panoramax are next. The panel is kept simple: the viewer opens at the top so the photo needs no scrolling, the header pill and the provider chips switch the layer, and SINCE is a slider. FOLLOW (the globe camera following the street-level view) is available on the Google 3D map only.
|
||||
|
||||
- Street Level draws each imagery source in one colour, everywhere it appears: its PROVIDERS chip, coverage lines, overview points, image cones and a single legend swatch. Mapillary is green (no more age dimming or magenta panoramas; 360° cones are rings, and the 360°/FLAT filter still separates them); Panoramax will be purple and Google Street View blue.
|
||||
|
||||
- Street Level coverage no longer floats over trees and bridges on the Google 3D map. At street zoom, sequence lines, image cones and the position marker are placed on the bare earth from the terrain service (with a 2 m lift) instead of being draped on the top of the photoreal mesh, so buildings and tree canopies hide what is behind them; other maps and higher views keep draped lines. Opening a photo no longer flies the camera underground when the Google tiles under it have not loaded yet.
|
||||
|
||||
- CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived
|
||||
|
||||
@@ -336,7 +336,7 @@ Setup for Mapillary:
|
||||
|
||||
Without the token the STREET LEVEL panel reads **KEY REQUIRED**, the Mapillary chip turns amber and the controls stay greyed out.
|
||||
|
||||
Turn on **Street Level** under DATA LAYERS → Cameras, or open the STREET LEVEL strip in the right rail and press the **ON/OFF** pill in its header. The **PROVIDERS** row has one chip per registered provider: lighting a chip turns its imagery on (and the layer with it), and darkening the last lit chip turns the layer off. The **IMAGERY** filter (ALL / 360° / FLAT) and the **SINCE** slider (any date on the left, the last month on the right, with the cut-off date spelled out) apply to every provider at once. Mapillary's coverage draws as ground-hugging lines (recent captures bright green, older ones dim, 360° panoramas magenta); click a line for its image cones and a cone to open the photo. The photo opens at the top of the panel, so it is on screen without scrolling. The bar above it has **EXPAND** (most of the screen), **FIT** (the whole photo, with dark bars where its shape differs from the frame) / **FILL** (edge to edge, cropped) and **FOLLOW** (the globe camera moves wherever the street-level view looks; available on the Google 3D map only, since flat imagery has nothing to stand among); the caption links to the image on the provider's own site. Mapillary blurs faces and licence plates before publishing imagery, and the globe credits "© Mapillary contributors, CC BY-SA 4.0" while the provider is on.
|
||||
Turn on **Street Level** under DATA LAYERS → Cameras, or open the STREET LEVEL strip in the right rail and press the **ON/OFF** pill in its header. The **PROVIDERS** row has one chip per registered provider, in that source's colour (Mapillary green; Panoramax will be purple and Google Street View blue): lighting a chip turns its imagery on (and the layer with it), and darkening the last lit chip turns the layer off. The **IMAGERY** filter (ALL / 360° / FLAT) and the **SINCE** slider (any date on the left, the last month on the right, with the cut-off date spelled out) apply to every provider at once. Each source draws in its one colour, so Mapillary's coverage is green ground-hugging lines, overview points and image cones (360° cones are rings); click a line for its image cones and a cone to open the photo. The photo opens at the top of the panel, so it is on screen without scrolling. The bar above it has **EXPAND** (most of the screen), **FIT** (the whole photo, with dark bars where its shape differs from the frame) / **FILL** (edge to edge, cropped) and **FOLLOW** (the globe camera moves wherever the street-level view looks; available on the Google 3D map only, since flat imagery has nothing to stand among); the caption links to the image on the provider's own site. Mapillary blurs faces and licence plates before publishing imagery, and the globe credits "© Mapillary contributors, CC BY-SA 4.0" while the provider is on.
|
||||
|
||||
The panel is also a window: drag its header to lift it out of the rail, and resize it from any edge or corner. A larger window gives the photo the room; only the settings below it scroll. Double-clicking the header, collapsing the window, or pressing SHRINK after EXPAND puts it back in the rail at its default size, so it never stays parked over the globe.
|
||||
|
||||
|
||||
@@ -2778,7 +2778,11 @@ collapsible panel (`#street-level-panel`, share token `0`, option owner
|
||||
overlay: one panel, a PROVIDERS chip per registered imagery provider, shared
|
||||
360°/flat and captured-since filters (stored as relative days so a link keeps
|
||||
its meaning), one viewer host, and one on-globe credit per active provider.
|
||||
Share options: `m` (Mapillary on/off), `p` (a/p/f panorama mode), `s`
|
||||
Each source has one colour (`PROVIDER_COLORS` in
|
||||
`src/layers/streetLevel/policy.js`: Mapillary green `#05cb63`, Panoramax
|
||||
purple, Google Street View blue), used for its chip, coverage lines, overview
|
||||
points, image cones and its single legend swatch; 360° cones are rings, and
|
||||
the selected sequence is GEV cyan. Share options: `m` (Mapillary on/off), `p` (a/p/f panorama mode), `s`
|
||||
(since, days). Only Mapillary is registered in this build. Without
|
||||
`MAPILLARY_CLIENT_TOKEN` the header reads KEY REQUIRED, the Mapillary chip
|
||||
turns amber and the controls stay disabled; with it, coverage draws as
|
||||
@@ -2831,8 +2835,8 @@ the whole photo and caption fit without scrolling.
|
||||
|
||||
Street-level providers implement the contract documented in
|
||||
`src/layers/streetLevel/registry.js`: a definition (`id`, `name`, `label`,
|
||||
`requiresKeyId`, `pickPrefix`, `colors`, `credit`, `capabilities`, `legend`,
|
||||
`externalUrl`, `create`) whose `create(context)` returns an instance with
|
||||
`requiresKeyId`, `pickPrefix`, `colors` (`{coverage}`, the source's one
|
||||
colour), `credit`, `capabilities`, `externalUrl`, `create`) whose `create(context)` returns an instance with
|
||||
`status`, `init`/`activate`/`deactivate`/`destroy`, `refreshCoverage`,
|
||||
`setFilter`, optional `setSurface`, `coverageStats`, `handlePick`, optional
|
||||
sequence selection,
|
||||
|
||||
@@ -137,11 +137,18 @@ async function main() {
|
||||
].map((chip) => chip.dataset.chipId),
|
||||
);
|
||||
assert.deepEqual(chips, EXPECTED_PROVIDERS);
|
||||
// One swatch per source plus "Selected"; each chip wears its colour.
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.querySelectorAll('#sl-legend li').length,
|
||||
),
|
||||
4,
|
||||
EXPECTED_PROVIDERS.length + 1,
|
||||
);
|
||||
assert.equal(
|
||||
await page.$eval('[data-chip-id="mapillary"]', (chip) =>
|
||||
chip.style.getPropertyValue('--chip-color'),
|
||||
),
|
||||
'#05cb63',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -28,6 +28,17 @@ export function syncChipGroup(container, chips = [], { before = null } = {}) {
|
||||
`data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`,
|
||||
);
|
||||
set(button, 'textContent', chip.label);
|
||||
// Optional per-chip colour (a Street Level source); CSS decides its use.
|
||||
const color = chip.color || '';
|
||||
if ((button.dataset.chipColor || '') !== color) {
|
||||
if (color) {
|
||||
button.style.setProperty('--chip-color', color);
|
||||
button.dataset.chipColor = color;
|
||||
} else {
|
||||
button.style.removeProperty('--chip-color');
|
||||
delete button.dataset.chipColor;
|
||||
}
|
||||
}
|
||||
set(button, 'title', chip.title || '');
|
||||
set(button, 'disabled', Boolean(chip.disabled));
|
||||
attribute(button, 'aria-pressed', String(Boolean(chip.active)));
|
||||
|
||||
@@ -31,3 +31,24 @@ test('keyed chips retain focus, reflect state, reorder and remove without redund
|
||||
syncChipGroup(f.container, []);
|
||||
assert.deepEqual(f.container.children, [legend]);
|
||||
});
|
||||
|
||||
test('a chip colour is set once, changed, and cleared through a CSS variable', () => {
|
||||
const f = railFixture(() => {});
|
||||
const calls = [];
|
||||
syncChipGroup(f.container, [{ id: 'm', label: 'M' }]);
|
||||
const chip = f.container.children[0];
|
||||
chip.style.setProperty = (name, value) => calls.push(['set', name, value]);
|
||||
chip.style.removeProperty = (name) => calls.push(['remove', name]);
|
||||
syncChipGroup(f.container, [{ id: 'm', label: 'M' }]);
|
||||
assert.deepEqual(calls, [], 'a chip without a colour never touches style');
|
||||
syncChipGroup(f.container, [{ id: 'm', label: 'M', color: '#05cb63' }]);
|
||||
syncChipGroup(f.container, [{ id: 'm', label: 'M', color: '#05cb63' }]);
|
||||
syncChipGroup(f.container, [{ id: 'm', label: 'M', color: '#a66bff' }]);
|
||||
syncChipGroup(f.container, [{ id: 'm', label: 'M' }]);
|
||||
assert.deepEqual(calls, [
|
||||
['set', '--chip-color', '#05cb63'],
|
||||
['set', '--chip-color', '#a66bff'],
|
||||
['remove', '--chip-color'],
|
||||
]);
|
||||
assert.equal(chip.dataset.chipColor, undefined);
|
||||
});
|
||||
@@ -91,6 +91,7 @@ function presentProviders(state) {
|
||||
return {
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
color: provider.color || null,
|
||||
title,
|
||||
active: on,
|
||||
disabled: false,
|
||||
|
||||
@@ -18,7 +18,7 @@ const provider = (overrides = {}) => ({
|
||||
count: 0,
|
||||
hint: '',
|
||||
error: null,
|
||||
legend: [],
|
||||
color: '#05cb63',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -30,9 +30,7 @@ function snapshot(overrides = {}) {
|
||||
providers: [provider()],
|
||||
coverage: { loading: false, count: 0, hint: '', error: null },
|
||||
legend: [
|
||||
{ key: 'mapillary:recent', label: 'Recent (≤2 yr)', color: '#05cb63' },
|
||||
{ key: 'mapillary:older', label: 'Older', color: '#2e7d5b' },
|
||||
{ key: 'mapillary:pano', label: '360°', color: '#ff4fd8' },
|
||||
{ key: 'mapillary', label: 'Mapillary', color: '#05cb63' },
|
||||
{ key: 'selected', label: 'Selected', color: '#00d4ff' },
|
||||
],
|
||||
sequence: { providerId: null, selectedId: null, images: 0, loading: false },
|
||||
@@ -239,7 +237,25 @@ test('legend passes through in the layer’s order', () => {
|
||||
const view = presentStreetLevelPanel(snapshot());
|
||||
assert.deepEqual(
|
||||
view.legend.map((entry) => entry.key),
|
||||
['mapillary:recent', 'mapillary:older', 'mapillary:pano', 'selected'],
|
||||
['mapillary', 'selected'],
|
||||
);
|
||||
});
|
||||
|
||||
test('each provider chip carries its source colour', () => {
|
||||
const view = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
providers: [
|
||||
provider(),
|
||||
provider({ id: 'panoramax', label: 'PANORAMAX', color: '#a66bff' }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(
|
||||
view.providers.map((chip) => [chip.id, chip.color]),
|
||||
[
|
||||
['mapillary', '#05cb63'],
|
||||
['panoramax', '#a66bff'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -139,6 +139,38 @@ button.sl-status:focus-visible {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* One colour per source: a swatch dot, and the whole chip in that colour
|
||||
while the source is on. `--chip-color` comes from the provider. */
|
||||
.sl-chips .data-toggle-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.sl-chips .data-toggle-chip::before {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--chip-color, currentColor);
|
||||
opacity: 0.45;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.sl-chips .data-toggle-chip.active {
|
||||
color: var(--chip-color, var(--accent));
|
||||
border-color: var(--chip-color, var(--accent));
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--chip-color, var(--accent)) 14%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.sl-chips .data-toggle-chip.active::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sl-chips .data-toggle-chip.chip-error {
|
||||
color: #ffb300;
|
||||
border-color: rgba(255, 179, 0, 0.55);
|
||||
|
||||
Reference in new issue
Block a user