refactor(mapillary): remove the plain-English query feature

Street Level is imagery only from here on: coverage, sequences and the
embedded viewer. The Claude planner, map-feature and traffic-sign tiles,
the NDJSON feature stream, sprites, detection outlines and analyst records
go, along with @anthropic-ai/sdk, zod and the Anthropic key entry.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Fable 5.1 committed 2026-09-25 18:08:43 +01:00
1 parent 9ba6f96d87
commit 547b706f67
44 files changed
+143 -5318

No files matched your search

+7 -133
View File
@@ -7,8 +7,8 @@ const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
/**
* Own the Street Level panel: the query box, result chips, progress, the
* imagery filters and the embedded MapillaryJS viewer. The panel itself is
* Own the Street Level panel: the imagery filters, the legend and the
* embedded MapillaryJS viewer. The panel itself is
* ordinary GEV chrome (collapse button, rail layout, persistence) driven by
* the application shell; this class only fills the body and asks the shell
* to open the panel when something worth seeing arrives.
@@ -25,7 +25,6 @@ export class MapillaryControls {
this._view = null;
this._wasEnabled = null;
this._wasStreetOpen = false;
this._lastStage = null;
this._wrapHome = null;
this._expandReturnFocus = null;
this._elements = this._collect();
@@ -39,20 +38,8 @@ export class MapillaryControls {
controls: byId('mly-controls'),
enableBtn: byId('mly-enable-btn'),
lookBtn: byId('mly-look-btn'),
form: byId('mly-query-form'),
input: byId('mly-query-input'),
runBtn: byId('mly-query-run'),
hint: byId('mly-ai-hint'),
answer: byId('mly-answer'),
error: byId('mly-error'),
errorText: byId('mly-error-text'),
progress: byId('mly-progress'),
progressFill: byId('mly-progress-fill'),
progressLabel: byId('mly-progress-label'),
results: byId('mly-results'),
chips: byId('mly-result-chips'),
frameBtn: byId('mly-frame-btn'),
clearBtn: byId('mly-clear-btn'),
sinceSelect: byId('mly-since'),
legend: byId('mly-legend'),
followBtn: byId('mly-follow-btn'),
@@ -85,28 +72,6 @@ export class MapillaryControls {
if (!(await this._ensureEnabled())) return;
this.mapillary.openNearest?.();
});
this.listen(el.form, 'submit', (event) => {
event.preventDefault();
if (this._view?.query.submitIsStop) this.mapillary.abortQuery?.();
else this._run(el.input?.value);
});
for (const button of this.root.querySelectorAll('[data-mly-suggestion]')) {
this.listen(button, 'click', () => {
if (el.input) el.input.value = button.dataset.mlySuggestion || '';
this._run(button.dataset.mlySuggestion);
});
}
this.listen(el.frameBtn, 'click', () => this.mapillary.frameResults?.());
this.listen(el.clearBtn, 'click', () => this.mapillary.clearQuery?.());
this.listen(el.chips, 'click', (event) => {
const more = event.target.closest?.('[data-mly-more]');
if (!more) return;
const expanded = el.chips.classList.toggle('is-expanded');
more.setAttribute('aria-expanded', String(expanded));
more.textContent = expanded
? 'FEWER'
: `+${more.dataset.mlyMore} CLASSES`;
});
for (const button of this.root.querySelectorAll('[data-mly-pano]')) {
this.listen(button, 'click', () =>
this.mapillary.setCoverageFilter?.({ pano: button.dataset.mlyPano }),
@@ -148,13 +113,6 @@ export class MapillaryControls {
}
// The expanded viewer is a dialog: Esc closes it, Tab stays inside.
this.listen(el.viewerWrap, 'keydown', (event) => this._onDialogKey(event));
// Keep the app's keyboard shortcuts from firing while typing a query,
// but let Escape through so GEV's panel disclosure can collapse the panel.
const shield = (event) => {
if (event.key !== 'Escape') event.stopPropagation();
};
this.listen(el.input, 'keydown', shield);
this.listen(el.input, 'keyup', shield);
// MapillaryJS only tracks window resizes; the panel resizes on its own.
if (typeof ResizeObserver === 'function' && el.viewer) {
let queued = false;
@@ -192,13 +150,6 @@ export class MapillaryControls {
}
}
async _run(prompt) {
const text = String(prompt || '').trim();
if (!text) return;
if (!(await this._ensureEnabled())) return;
this.mapillary.runQuery?.(text);
}
connect() {
this._unsubscribe?.();
this._unsubscribe = null;
@@ -304,8 +255,7 @@ export class MapillaryControls {
this._view = view;
this._renderHeader(view);
this._renderGate(view);
this._renderQuery(view);
this._renderResults(view);
this._renderError(view);
this._renderFilters(view);
this._renderViewer(view, state);
this._renderMeta(view);
@@ -333,82 +283,11 @@ export class MapillaryControls {
if (el.controls) el.controls.disabled = view.controlsDisabled;
}
_renderQuery(view) {
_renderError(view) {
const el = this._elements;
const { query } = view;
if (el.input) {
el.input.placeholder = query.placeholder;
el.input.disabled = query.inputDisabled;
}
if (el.runBtn) {
el.runBtn.textContent = query.submitLabel;
el.runBtn.classList.toggle('is-stop', query.submitIsStop);
el.runBtn.disabled = query.inputDisabled && !query.submitIsStop;
el.runBtn.setAttribute(
'aria-label',
query.submitIsStop ? 'Stop the query' : 'Ask',
);
}
for (const button of this.root.querySelectorAll('[data-mly-suggestion]'))
button.disabled = query.suggestionsDisabled;
if (el.hint) {
el.hint.textContent = query.hint;
el.hint.classList.toggle('is-warn', query.hintWarn);
}
if (el.answer) el.answer.textContent = view.answer;
if (el.error) {
el.error.hidden = !view.error;
if (el.errorText) el.errorText.textContent = view.error || '';
}
if (el.progress) {
el.progress.hidden = !view.progress.visible;
el.progress.classList.toggle(
'is-indeterminate',
view.progress.indeterminate,
);
if (el.progressFill && !view.progress.indeterminate)
el.progressFill.style.width = `${view.progress.percent}%`;
if (el.progressFill && view.progress.indeterminate)
el.progressFill.style.removeProperty('width');
if (el.progressLabel) el.progressLabel.textContent = view.progress.label;
}
}
_renderResults(view) {
const el = this._elements;
if (!el.results) return;
el.results.hidden = !view.results.visible;
if (!view.results.visible || !el.chips) return;
const expanded = el.chips.classList.contains('is-expanded');
const chip = (entry, overflow) => {
const node = document.createElement('span');
node.className = `mly-value-chip${overflow ? ' is-overflow' : ''}`;
node.title = entry.value;
const dot = document.createElement('i');
dot.style.background = entry.color;
const text = document.createElement('span');
text.textContent = entry.label;
const num = document.createElement('b');
num.textContent = entry.count;
node.append(dot, text, num);
return node;
};
const nodes = view.results.all.map((entry, index) =>
chip(entry, index >= view.results.chips.length),
);
if (view.results.more) {
const more = document.createElement('button');
more.type = 'button';
more.className = 'mly-value-chip';
more.dataset.mlyMore = String(view.results.more.count);
more.title = view.results.more.title;
more.setAttribute('aria-expanded', String(expanded));
more.textContent = expanded
? 'FEWER'
: `+${view.results.more.count} CLASSES`;
nodes.push(more);
} else el.chips.classList.remove('is-expanded');
el.chips.replaceChildren(...nodes);
if (!el.error) return;
el.error.hidden = !view.error;
if (el.errorText) el.errorText.textContent = view.error || '';
}
_renderFilters(view) {
@@ -471,11 +350,6 @@ export class MapillaryControls {
this._wasEnabled = enabled;
if (state.street.open && !this._wasStreetOpen) this.setCollapsed(false);
this._wasStreetOpen = state.street.open === true;
if (state.query.stage !== this._lastStage) {
this._lastStage = state.query.stage;
if (state.query.stage === 'done' || state.query.stage === 'error')
this.setCollapsed(false);
}
}
destroy() {
+7 -126
View File
@@ -3,15 +3,6 @@
* renders. Pure: no DOM, no layer calls, so every wording decision is testable.
*/
const HINT_READY =
'Objects, traffic signs, coverage or a street view of any place. Follow-ups refine the last answer.';
const HINT_NO_PLANNER =
'Plain-English questions need an Anthropic key in POWER UP. Coverage, photos and filters work without it.';
const IDLE_ANSWER_OFF =
'Turn on Street Level to see Mapillary coverage on the globe.';
const IDLE_ANSWER_ON =
'Click a green line to see its photos, or ask for objects and signs.';
function formatDate(ms) {
if (!Number.isFinite(ms)) return '';
try {
@@ -30,84 +21,12 @@ export function mapillaryImageUrl(imageId) {
function presentStatus(state) {
if (state.keyRequired) return { text: 'KEY REQUIRED', tone: 'warn' };
if (state.query.busy)
return { text: `AI · ${state.query.stage.toUpperCase()}`, tone: 'busy' };
if (state.coverage.loading || state.features.loading)
return { text: 'LOADING', tone: 'busy' };
if (state.coverage.loading) return { text: 'LOADING', tone: 'busy' };
return state.enabled ? { text: 'ON', tone: 'on' } : { text: 'OFF', tone: '' };
}
function presentProgress(state) {
const { stage, busy } = state.query;
const { progress, loading, total } = state.features;
if (loading && progress.tiles > 0) {
const pct = Math.round((progress.done / progress.tiles) * 100);
return {
visible: true,
indeterminate: false,
percent: pct,
label: `${progress.done}/${progress.tiles} tiles · ${total.toLocaleString()} found`,
};
}
if (busy && stage === 'planning')
return {
visible: true,
indeterminate: true,
percent: 0,
label: 'Planning…',
};
if (busy && stage === 'resolving')
return {
visible: true,
indeterminate: true,
percent: 0,
label: `Finding ${state.query.place || 'the area'}…`,
};
if (busy)
return {
visible: true,
indeterminate: true,
percent: 0,
label: 'Working…',
};
return { visible: false, indeterminate: false, percent: 0, label: '' };
}
function presentResults(state, { maxChips = 8 } = {}) {
const counts = state.features.counts || [];
const visible = state.features.total > 0 || state.features.hasBbox === true;
const chips = counts.map(({ value, count, label, color }) => ({
value,
label,
color,
count: count.toLocaleString(),
}));
const hidden = chips.slice(maxChips);
return {
visible,
chips: chips.slice(0, maxChips),
more: hidden.length
? {
count: hidden.length,
title: hidden.map((chip) => `${chip.label} ${chip.count}`).join(', '),
}
: null,
all: chips,
};
}
function presentViewer(state) {
const { street } = state;
const left = [];
if (street.feature?.label)
left.push(
`${street.feature.label}${street.feature.imageCount ? ` · ${street.feature.imageCount} sightings` : ''}`,
);
if (street.highlight?.count)
left.push(
`${street.highlight.count} detection${street.highlight.count === 1 ? '' : 's'} outlined`,
);
if (street.creator) left.push(`Image by ${street.creator}`);
const right = [];
if (street.isPano) right.push('360°');
if (Number.isFinite(street.bearing))
@@ -117,7 +36,7 @@ function presentViewer(state) {
open: street.open === true,
loading: street.loading === true && !street.imageId,
renderMode: street.renderMode === 'fill' ? 'fill' : 'letterbox',
captionLeft: left.join(' · '),
captionLeft: street.creator ? `Image by ${street.creator}` : '',
captionRight: right.join(' · '),
link: street.imageId ? mapillaryImageUrl(street.imageId) : null,
follow: {
@@ -133,17 +52,9 @@ function presentMeta(state) {
return state.sequence.loading
? 'Loading this sequence…'
: `${state.sequence.images.toLocaleString()} images in this sequence · Esc clears`;
const bits = [];
if (state.coverage.zoom && state.coverage.sequences > 0)
bits.push(
`${state.coverage.sequences.toLocaleString()} sequences in view · click a line for its photos`,
);
else if (state.coverage.hint) bits.push(state.coverage.hint);
if (state.query.usage?.model)
bits.push(
`${state.query.usage.model} · ${state.query.usage.input_tokens ?? '?'}→${state.query.usage.output_tokens ?? '?'} tok`,
);
return bits.join(' · ');
return `${state.coverage.sequences.toLocaleString()} sequences in view · click a line for its photos`;
return state.coverage.hint || '';
}
/**
@@ -153,18 +64,6 @@ function presentMeta(state) {
export function presentMapillaryPanel(state) {
const enabled = state.enabled === true;
const keyRequired = state.keyRequired === true;
// Unknown (status not fetched yet) reads as available: the server answers
// a missing key with its own error, and the panel must not claim one is missing.
const planner = state.planner !== false;
const busy = state.query.busy === true;
const error = state.query.error || state.street.error || null;
let answer = state.query.answer || '';
if (!answer && !error) {
if (busy) answer = '';
else if (!enabled) answer = IDLE_ANSWER_OFF;
else if (state.coverage.hint) answer = state.coverage.hint;
else answer = IDLE_ANSWER_ON;
}
return {
enabled,
keyRequired,
@@ -174,30 +73,12 @@ export function presentMapillaryPanel(state) {
text: enabled ? 'STREET LEVEL ON' : 'STREET LEVEL OFF',
pressed: enabled,
},
query: {
placeholder: planner
? 'Ask about objects, signs or a place'
: 'Anthropic key needed — see POWER UP',
inputDisabled: !planner,
suggestionsDisabled: !planner,
hint: planner ? HINT_READY : HINT_NO_PLANNER,
hintWarn: !planner,
model: state.plannerModel ? `· ${state.plannerModel}` : '',
submitLabel: busy ? 'STOP' : 'ASK',
submitIsStop: busy,
},
progress: presentProgress(state),
answer,
error,
results: presentResults(state),
error: state.street.error || state.coverage.error || null,
filter: state.coverage.filter || { pano: 'all', sinceMs: null },
legend: state.coverage.legend || [],
viewer: presentViewer(state),
meta: presentMeta(state),
/** The panel opens itself at these moments (a native panel stays put otherwise). */
wantsOpen:
state.street.open === true ||
state.query.stage === 'done' ||
state.query.stage === 'error',
/** The panel opens itself when an image opens (a native panel stays put otherwise). */
wantsOpen: state.street.open === true,
};
}
+43 -112
View File
@@ -9,8 +9,6 @@ function snapshot(overrides = {}) {
const base = {
enabled: false,
keyRequired: false,
planner: true,
plannerModel: 'claude-opus-5',
coverage: {
zoom: null,
kind: null,
@@ -27,19 +25,6 @@ function snapshot(overrides = {}) {
error: null,
},
sequence: { selectedId: null, images: 0, loading: false },
features: {
total: 0,
title: '',
loading: false,
progress: { done: 0, tiles: 0 },
counts: [],
truncated: false,
failed: 0,
iconMode: false,
hasBbox: false,
selectedId: null,
bbox: null,
},
street: {
open: false,
follow: false,
@@ -53,19 +38,6 @@ function snapshot(overrides = {}) {
sequenceId: null,
creator: null,
renderMode: 'letterbox',
feature: null,
highlight: null,
},
query: {
busy: false,
stage: 'idle',
prompt: '',
place: null,
answer: '',
error: null,
title: '',
usage: null,
history: [],
},
};
return deepMerge(base, overrides);
@@ -92,90 +64,36 @@ test('a missing Mapillary token gates every control and flags KEY REQUIRED', ()
assert.deepEqual(view.status, { text: 'KEY REQUIRED', tone: 'warn' });
});
test('without an Anthropic key the query box explains itself and stays disabled', () => {
const view = presentMapillaryPanel(
snapshot({ planner: false, enabled: true }),
);
assert.equal(view.query.inputDisabled, true);
assert.equal(view.query.suggestionsDisabled, true);
assert.equal(view.query.hintWarn, true);
assert.match(view.query.hint, /Anthropic key/);
assert.ok(view.query.placeholder.length < 40, 'placeholder fits the field');
assert.equal(view.controlsDisabled, false);
});
test('before the status call answers the query box does not claim a missing key', () => {
const view = presentMapillaryPanel(snapshot({ planner: null }));
assert.equal(view.query.inputDisabled, false);
assert.equal(view.query.hintWarn, false);
assert.doesNotMatch(view.query.placeholder, /Anthropic/);
});
test('a running query turns ASK into STOP and shows indeterminate progress per stage', () => {
const planning = presentMapillaryPanel(
snapshot({ enabled: true, query: { busy: true, stage: 'planning' } }),
);
assert.equal(planning.query.submitLabel, 'STOP');
assert.equal(planning.query.submitIsStop, true);
assert.deepEqual(planning.progress, {
visible: true,
indeterminate: true,
percent: 0,
label: 'Planning…',
test('status reads LOADING while coverage streams, then ON or OFF', () => {
assert.deepEqual(presentMapillaryPanel(snapshot()).status, {
text: 'OFF',
tone: '',
});
assert.equal(planning.status.text, 'AI · PLANNING');
const resolving = presentMapillaryPanel(
snapshot({
enabled: true,
query: { busy: true, stage: 'resolving', place: 'Sacramento, CA' },
}),
assert.deepEqual(
presentMapillaryPanel(
snapshot({ enabled: true, coverage: { loading: true } }),
).status,
{ text: 'LOADING', tone: 'busy' },
);
assert.equal(resolving.progress.label, 'Finding Sacramento, CA…');
const fetching = presentMapillaryPanel(
snapshot({
enabled: true,
query: { busy: true, stage: 'fetching' },
features: { loading: true, progress: { done: 3, tiles: 12 }, total: 410 },
}),
);
assert.equal(fetching.progress.indeterminate, false);
assert.equal(fetching.progress.percent, 25);
assert.equal(fetching.progress.label, '3/12 tiles · 410 found');
assert.deepEqual(presentMapillaryPanel(snapshot({ enabled: true })).status, {
text: 'ON',
tone: 'on',
});
});
test('idle copy depends on whether the layer is on', () => {
assert.match(
presentMapillaryPanel(snapshot()).answer,
/Turn on Street Level/,
test('errors from the viewer or the coverage web surface in one alert', () => {
assert.equal(presentMapillaryPanel(snapshot()).error, null);
assert.equal(
presentMapillaryPanel(
snapshot({ street: { error: 'Image could not be opened' } }),
).error,
'Image could not be opened',
);
assert.match(
presentMapillaryPanel(snapshot({ enabled: true })).answer,
/Click a green line/,
assert.equal(
presentMapillaryPanel(snapshot({ coverage: { error: 'Tile HTTP 502' } }))
.error,
'Tile HTTP 502',
);
const view = presentMapillaryPanel(
snapshot({ enabled: true, query: { error: 'Planner unavailable' } }),
);
assert.equal(view.error, 'Planner unavailable');
assert.equal(view.answer, '');
});
test('result chips cap at eight and describe the overflow', () => {
const counts = Array.from({ length: 11 }, (_, i) => ({
value: `object--v${i}`,
label: `Class ${i}`,
color: '#fff',
count: 100 - i,
}));
const view = presentMapillaryPanel(
snapshot({ enabled: true, features: { total: 900, counts } }),
);
assert.equal(view.results.visible, true);
assert.equal(view.results.chips.length, 8);
assert.equal(view.results.more.count, 3);
assert.match(view.results.more.title, /Class 8 92, Class 9 91, Class 10 90/);
assert.equal(view.results.all.length, 11);
});
test('legend passes through in the layer’s order', () => {
@@ -187,6 +105,7 @@ test('legend passes through in the layer’s order', () => {
});
test('the meta line never mixes the visible-sequence count with the selected sequence', () => {
assert.equal(presentMapillaryPanel(snapshot()).meta, '');
const browsing = presentMapillaryPanel(
snapshot({ enabled: true, coverage: { zoom: 14, sequences: 812 } }),
);
@@ -200,6 +119,13 @@ test('the meta line never mixes the visible-sequence count with the selected seq
);
assert.equal(selected.meta, '33 images in this sequence · Esc clears');
assert.doesNotMatch(selected.meta, /sequences in view/);
const hinted = presentMapillaryPanel(
snapshot({
enabled: true,
coverage: { hint: 'Point the camera at the globe' },
}),
);
assert.equal(hinted.meta, 'Point the camera at the globe');
});
test('viewer caption reads "Image by" left and date right, with a deep link', () => {
@@ -213,15 +139,10 @@ test('viewer caption reads "Image by" left and date right, with a deep link', ()
capturedAt: Date.UTC(2023, 9, 8),
bearing: 93.4,
isPano: true,
feature: { label: 'Fire hydrant', imageCount: 5 },
highlight: { count: 2 },
},
}),
);
assert.equal(
view.viewer.captionLeft,
'Fire hydrant · 5 sightings · 2 detections outlined · Image by mapfool',
);
assert.equal(view.viewer.captionLeft, 'Image by mapfool');
assert.equal(view.viewer.captionRight, '360° · 93° · 2023-10-08');
assert.equal(
view.viewer.link,
@@ -231,6 +152,16 @@ test('viewer caption reads "Image by" left and date right, with a deep link', ()
assert.equal(view.wantsOpen, true);
});
test('an image without a creator name leaves the left caption empty', () => {
const view = presentMapillaryPanel(
snapshot({
street: { open: true, imageId: '1', capturedAt: Date.UTC(2024, 0, 2) },
}),
);
assert.equal(view.viewer.captionLeft, '');
assert.equal(view.viewer.captionRight, '2024-01-02');
});
test('image deep links match the mapillary.com share format', () => {
assert.equal(
mapillaryImageUrl(1814275685699406),
+1 -221
View File
@@ -110,126 +110,7 @@
pointer-events: none;
}
/* ── Query ───────────────────────────────────────────────────────────────── */
.mly-query {
display: flex;
flex-direction: column;
gap: 6px;
}
.mly-query-row {
display: flex;
gap: 6px;
}
#mly-query-input {
flex: 1;
min-width: 0;
padding: 9px 11px;
border-radius: var(--btn-radius);
border: 1px solid rgba(0, 212, 255, 0.45);
background: rgba(0, 212, 255, 0.06);
color: var(--text-primary);
font: inherit;
font-family: var(--font-mono);
font-size: 0.74rem;
}
#mly-query-input::placeholder {
color: rgba(232, 234, 237, 0.55);
}
#mly-query-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-dim);
}
#mly-query-input:disabled {
border-color: var(--glass-border);
background: rgba(255, 255, 255, 0.03);
}
.mly-ask {
flex: 0 0 auto;
min-width: 64px;
color: #0a0a0f;
background: var(--accent);
border-color: var(--accent);
font-weight: 600;
}
.mly-ask:hover:not(:disabled) {
color: #0a0a0f;
background: var(--accent);
box-shadow: 0 0 16px rgba(0, 212, 255, 0.5);
}
.mly-ask.is-stop {
color: #ff8a80;
background: rgba(255, 88, 88, 0.15);
border-color: #ff7272;
}
.mly-ask.is-stop:hover:not(:disabled) {
color: #ff8a80;
background: rgba(255, 88, 88, 0.25);
border-color: #ff7272;
}
.mly-hint {
margin: 0;
font-family: var(--font-sans);
font-size: 0.68rem;
line-height: 1.35;
color: var(--text-secondary);
}
.mly-hint.is-warn {
color: #ffb300;
}
.mly-suggestions {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.mly-suggestion {
font-family: var(--font-mono);
font-size: var(--mly-text-small);
letter-spacing: 0.06em;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid var(--glass-border);
background: rgba(255, 255, 255, 0.03);
color: var(--text-secondary);
cursor: pointer;
}
.mly-suggestion:hover:not(:disabled) {
color: var(--text-primary);
border-color: var(--glass-border-hover);
}
.mly-suggestion:disabled {
opacity: 0.5;
cursor: default;
}
/* ── Answer, error, progress ─────────────────────────────────────────────── */
.mly-answer {
font-family: var(--font-sans);
font-size: 0.74rem;
line-height: 1.4;
color: var(--text-primary);
min-height: 1.2em;
}
.mly-answer:empty {
display: none;
}
/* ── Errors ──────────────────────────────────────────────────────────────── */
.mly-error {
font-family: var(--font-sans);
font-size: 0.72rem;
@@ -237,102 +118,6 @@
color: #ff8a80;
}
.mly-progress {
position: relative;
height: 14px;
border-radius: 999px;
overflow: hidden;
background: rgba(255, 255, 255, 0.06);
}
.mly-progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--mly-green), var(--accent));
transition: width 160ms linear;
}
.mly-progress.is-indeterminate .mly-progress-fill {
width: 40%;
animation: mly-indeterminate 1.4s ease-in-out infinite;
}
@keyframes mly-indeterminate {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(250%);
}
}
.mly-progress-label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: var(--mly-text-small);
letter-spacing: 0.1em;
color: var(--text-primary);
text-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
}
/* ── Results ─────────────────────────────────────────────────────────────── */
.mly-results {
display: flex;
flex-direction: column;
gap: 8px;
}
.mly-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.mly-chips.is-expanded .mly-value-chip.is-overflow {
display: inline-flex;
}
.mly-value-chip {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: var(--mly-text-small);
padding: 3px 8px;
border-radius: 999px;
border: 1px solid var(--glass-border);
background: transparent;
color: var(--text-primary);
}
.mly-value-chip.is-overflow {
display: none;
}
.mly-value-chip i {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.mly-value-chip b {
color: var(--text-secondary);
font-weight: 500;
}
button.mly-value-chip {
cursor: pointer;
}
button.mly-value-chip:hover {
border-color: var(--glass-border-hover);
}
/* ── Filters and legend ──────────────────────────────────────────────────── */
.mly-filters {
display: flex;
@@ -648,11 +433,6 @@ button.mly-value-chip:hover {
gap: 6px;
}
#mapillary-panel .mly-hint,
#mapillary-panel .mly-suggestions {
display: none;
}
.mly-viewer {
min-height: 150px;
}
-26
View File
@@ -92,33 +92,7 @@
<button id="mly-enable-btn" class="scene-btn" type="button" aria-pressed="false" title="Turn the Street Level layer on or off">STREET LEVEL OFF</button>
<button id="mly-look-btn" class="scene-btn" type="button" title="Open the nearest Mapillary photo to the view centre">OPEN NEAREST PHOTO</button>
</div>
<form id="mly-query-form" class="mly-query" autocomplete="off">
<label for="mly-query-input" class="cctv-summary-label">ASK IN PLAIN ENGLISH</label>
<div class="mly-query-row">
<input id="mly-query-input" type="text" maxlength="600" spellcheck="false" placeholder="Ask about objects, signs or a place" aria-label="Ask about objects, signs or a place" />
<button id="mly-query-run" class="scene-btn mly-ask" type="submit">ASK</button>
</div>
<p id="mly-ai-hint" class="mly-hint">Objects, traffic signs, coverage or a street view of any place. Follow-ups refine the last answer.</p>
<div class="mly-suggestions" role="group" aria-label="Example queries">
<button type="button" class="mly-suggestion" data-mly-suggestion="show me all fire hydrants in Sacramento">Hydrants · Sacramento</button>
<button type="button" class="mly-suggestion" data-mly-suggestion="stop signs in downtown Detroit">Stop signs · Detroit</button>
<button type="button" class="mly-suggestion" data-mly-suggestion="cctv cameras in London">CCTV cameras · London</button>
<button type="button" class="mly-suggestion" data-mly-suggestion="show me a 360 street view here">360° view · here</button>
</div>
</form>
<div id="mly-answer" class="mly-answer" role="status" aria-live="polite"></div>
<div id="mly-error" class="mly-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="mly-error-text"></span></div>
<div id="mly-progress" class="mly-progress" hidden>
<div id="mly-progress-fill" class="mly-progress-fill"></div>
<span id="mly-progress-label" class="mly-progress-label"></span>
</div>
<div id="mly-results" class="mly-results" hidden>
<div id="mly-result-chips" class="mly-chips"></div>
<div class="cctv-controls">
<button id="mly-frame-btn" class="scene-btn" type="button" title="Fly the camera to the result area">ZOOM TO RESULTS</button>
<button id="mly-clear-btn" class="scene-btn" type="button" title="Remove the results from the globe">CLEAR</button>
</div>
</div>
<div class="mly-filters" role="group" aria-label="Imagery filters">
<span class="cctv-summary-label">IMAGERY</span>
<div class="mly-seg" role="radiogroup" aria-label="Panorama filter">