mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge origin/main: Panoramax, bikeshare Norway/Detroit, cloud filter strength, Overpass cache
Conflicts:
- KartaView viewer moved into the shared photoViewer.js on main; its
labels now take the provider name ("{name} street-level photo") and
are translated there.
- Weather: main's cloud filter strength slider, with its labels in t().
- Street Level caption: translated creator line plus main's licence.
Panoramax status and error texts are marked like KartaView's, and
"Panoramax answered HTTP {status}" gets a template in the panel and the
layer row. Catalogs: es/ru 2553 messages each, complete.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
7fd1dd867f
77 files changed
+9201
-919
No files matched your search
@@ -164,6 +164,10 @@ const FEED_ERROR_PATTERNS = [
|
||||
/^KartaView answered HTTP (\d+)$/,
|
||||
([, status]) => t('KartaView answered HTTP {status}', { status }),
|
||||
],
|
||||
[
|
||||
/^Panoramax answered HTTP (\d+)$/,
|
||||
([, status]) => t('Panoramax answered HTTP {status}', { status }),
|
||||
],
|
||||
[
|
||||
/^KartaView refused the request \(code (-?\d+)\)$/,
|
||||
([, code]) => t('KartaView refused the request (code {code})', { code }),
|
||||
|
||||
+105
-2
@@ -39,6 +39,7 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
|
||||
};
|
||||
const dispose = (block) => {
|
||||
block.removeClick?.();
|
||||
block.removeInput?.();
|
||||
block.children?.destroy();
|
||||
block.root.remove();
|
||||
};
|
||||
@@ -83,6 +84,66 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
|
||||
);
|
||||
block.chips = make('div', 'rail-card-chips', root);
|
||||
block.chips.setAttribute('role', 'group');
|
||||
block.rangeRow = make('div', 'rail-card-setting-range-row', root);
|
||||
block.rangeLabel = make(
|
||||
'span',
|
||||
'rail-card-setting-range-label',
|
||||
block.rangeRow,
|
||||
);
|
||||
block.rangeInput = make(
|
||||
'input',
|
||||
'rail-card-setting-range',
|
||||
block.rangeRow,
|
||||
);
|
||||
block.rangeInput.type = 'range';
|
||||
block.rangeValue = make(
|
||||
'span',
|
||||
'rail-card-setting-range-value',
|
||||
block.rangeRow,
|
||||
);
|
||||
const rangeParams = () => {
|
||||
const range = block.props.range;
|
||||
const value = Number(block.rangeInput.value);
|
||||
if (
|
||||
!range?.param ||
|
||||
range.disabled ||
|
||||
!Number.isFinite(value) ||
|
||||
value < Number(range.min) ||
|
||||
value > Number(range.max)
|
||||
)
|
||||
return null;
|
||||
return { params: { [range.param]: value } };
|
||||
};
|
||||
const onInput = () => {
|
||||
const range = block.props.range;
|
||||
const value = Number(block.rangeInput.value);
|
||||
const params = rangeParams();
|
||||
if (!params) return;
|
||||
if (range.commitOnChange) block.rangeDraft = { range, value };
|
||||
set(
|
||||
block.rangeValue,
|
||||
'textContent',
|
||||
`${value}${range.unit || ''}`,
|
||||
);
|
||||
attribute(
|
||||
block.rangeInput,
|
||||
'aria-valuetext',
|
||||
`${value}${range.unit || ''}`,
|
||||
);
|
||||
if (!range.commitOnChange) dispatch(params);
|
||||
};
|
||||
const onChange = () => {
|
||||
if (!block.props.range?.commitOnChange) return;
|
||||
const params = rangeParams();
|
||||
block.rangeDraft = null;
|
||||
if (params) dispatch(params);
|
||||
};
|
||||
block.rangeInput.addEventListener('input', onInput);
|
||||
block.rangeInput.addEventListener('change', onChange);
|
||||
block.removeInput = () => {
|
||||
block.rangeInput.removeEventListener('input', onInput);
|
||||
block.rangeInput.removeEventListener('change', onChange);
|
||||
};
|
||||
bind((event) => {
|
||||
const button = event.target?.closest?.('[data-chip-id]');
|
||||
dispatch(
|
||||
@@ -133,17 +194,59 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
|
||||
blocks.set(props.id, block);
|
||||
}
|
||||
block.props = props;
|
||||
if (props.type === 'settings')
|
||||
if (props.type === 'settings') {
|
||||
block.children.update(
|
||||
(props.settings || []).map((setting) => ({
|
||||
...setting,
|
||||
type: 'setting',
|
||||
})),
|
||||
);
|
||||
else if (props.type === 'setting') {
|
||||
} else if (props.type === 'setting') {
|
||||
set(block.label, 'textContent', props.label);
|
||||
attribute(block.chips, 'aria-label', props.label);
|
||||
syncChipGroup(block.chips, props.chips);
|
||||
const range = props.range;
|
||||
set(block.rangeRow, 'hidden', !range);
|
||||
const draft = block.rangeDraft;
|
||||
if (
|
||||
draft &&
|
||||
(!range ||
|
||||
range.disabled ||
|
||||
!range.commitOnChange ||
|
||||
['id', 'param', 'min', 'max', 'step', 'value'].some(
|
||||
(key) => range[key] !== draft.range[key],
|
||||
))
|
||||
)
|
||||
block.rangeDraft = null;
|
||||
if (range) {
|
||||
const min = Number(range.min);
|
||||
const max = Number(range.max);
|
||||
const step = Number(range.step);
|
||||
const candidate = Number(block.rangeDraft?.value ?? range.value);
|
||||
const value = Math.min(
|
||||
max,
|
||||
Math.max(min, Number.isFinite(candidate) ? candidate : min),
|
||||
);
|
||||
set(block.rangeLabel, 'textContent', range.label || '');
|
||||
set(block.rangeLabel, 'hidden', !range.label);
|
||||
set(block.rangeInput.dataset, 'rangeId', range.id || props.id);
|
||||
set(block.rangeInput, 'min', String(min));
|
||||
set(block.rangeInput, 'max', String(max));
|
||||
set(block.rangeInput, 'step', String(step));
|
||||
set(block.rangeInput, 'value', String(value));
|
||||
set(block.rangeInput, 'disabled', Boolean(range.disabled));
|
||||
attribute(
|
||||
block.rangeInput,
|
||||
'aria-label',
|
||||
range.ariaLabel || props.label,
|
||||
);
|
||||
attribute(
|
||||
block.rangeInput,
|
||||
'aria-valuetext',
|
||||
`${value}${range.unit || ''}`,
|
||||
);
|
||||
set(block.rangeValue, 'textContent', `${value}${range.unit || ''}`);
|
||||
}
|
||||
} else if (props.type === 'list') syncRowList(block.root, props.list);
|
||||
else if (props.type === 'result') {
|
||||
set(block.label, 'textContent', props.label);
|
||||
|
||||
@@ -119,6 +119,96 @@ test('footer buttons retain focus and current callbacks; links use safe new tabs
|
||||
button.click();
|
||||
assert.deepEqual(calls, [2]);
|
||||
});
|
||||
test('setting ranges stay below presets and commit expensive filters on release', () => {
|
||||
const f = railFixture();
|
||||
const calls = [];
|
||||
const view = createRailCards({
|
||||
...f,
|
||||
onParams: (...args) => calls.push(args),
|
||||
});
|
||||
const model = (value) => ({
|
||||
...card,
|
||||
blocks: [
|
||||
{
|
||||
id: 'settings',
|
||||
type: 'settings',
|
||||
settings: [
|
||||
{
|
||||
id: 'image',
|
||||
label: 'IMAGE',
|
||||
chips: [
|
||||
{
|
||||
id: 'clouds-only',
|
||||
label: 'Clouds only',
|
||||
params: { infrared: 100 },
|
||||
},
|
||||
{ id: 'full', label: 'Full', params: { infrared: 0 } },
|
||||
],
|
||||
range: {
|
||||
id: 'cloud-filter',
|
||||
param: 'infrared',
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
value,
|
||||
label: 'FILTER',
|
||||
unit: '%',
|
||||
ariaLabel: 'Cloud filter strength',
|
||||
commitOnChange: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
view.update([model(100)]);
|
||||
const setting = f.find((node) => node.dataset.blockId === 'image');
|
||||
assert.equal(setting.children[1].className, 'rail-card-chips');
|
||||
assert.equal(setting.children[2].className, 'rail-card-setting-range-row');
|
||||
assert.equal(setting.children[2].children[0].textContent, 'FILTER');
|
||||
const slider = f.find((node) => node.dataset.rangeId === 'cloud-filter');
|
||||
assert.equal(slider.tagName, 'INPUT');
|
||||
assert.equal(slider.type, 'range');
|
||||
assert.equal(slider.min, '0');
|
||||
assert.equal(slider.max, '100');
|
||||
assert.equal(slider.step, '1');
|
||||
assert.equal(slider.value, '100');
|
||||
assert.equal(slider.getAttribute('aria-label'), 'Cloud filter strength');
|
||||
slider.focus();
|
||||
slider.value = '53';
|
||||
slider.dispatchEvent(new Event('input'));
|
||||
assert.equal(calls.length, 0, 'expensive image work waits until release');
|
||||
assert.equal(
|
||||
f.find((node) => node.className === 'rail-card-setting-range-value')
|
||||
.textContent,
|
||||
'53%',
|
||||
);
|
||||
view.update([model(100)]);
|
||||
assert.equal(slider.value, '53', 'a status refresh retains the pending drag');
|
||||
assert.equal(
|
||||
slider.getAttribute('aria-valuetext'),
|
||||
'53%',
|
||||
'the accessible preview also survives a refresh',
|
||||
);
|
||||
slider.dispatchEvent(new Event('change'));
|
||||
assert.deepEqual(calls.at(-1), ['a', { infrared: 53 }]);
|
||||
view.update([model(53)]);
|
||||
assert.equal(slider.value, '53');
|
||||
assert.equal(f.document.activeElement, slider);
|
||||
slider.value = '41';
|
||||
slider.dispatchEvent(new Event('input'));
|
||||
view.update([model(70)]);
|
||||
assert.equal(
|
||||
slider.value,
|
||||
'70',
|
||||
'an authoritative value supersedes the draft',
|
||||
);
|
||||
view.destroy();
|
||||
slider.value = '41';
|
||||
slider.dispatchEvent(new Event('change'));
|
||||
assert.equal(calls.length, 1, 'destroy removes the range listener');
|
||||
});
|
||||
|
||||
test('legend validates colors, preserves the freezing anchor and ignores CSSOM normalization', () => {
|
||||
let writes = 0;
|
||||
const f = railFixture(() => writes++);
|
||||
|
||||
@@ -104,6 +104,10 @@ const ERROR_PATTERNS = [
|
||||
/^KartaView answered HTTP (\d+)$/,
|
||||
([, status]) => t('KartaView answered HTTP {status}', { status }),
|
||||
],
|
||||
[
|
||||
/^Panoramax answered HTTP (\d+)$/,
|
||||
([, status]) => t('Panoramax answered HTTP {status}', { status }),
|
||||
],
|
||||
[
|
||||
/^KartaView refused the request \(code (-?\d+)\)$/,
|
||||
([, code]) => t('KartaView refused the request (code {code})', { code }),
|
||||
@@ -172,6 +176,7 @@ function presentViewer(state) {
|
||||
if (street.title) left.push(street.title);
|
||||
if (street.creator)
|
||||
left.push(t('Image by {creator}', { creator: street.creator }));
|
||||
if (street.license) left.push(street.license);
|
||||
return {
|
||||
open: street.open === true,
|
||||
loading: street.loading === true && !street.imageId,
|
||||
|
||||
@@ -541,3 +541,25 @@ test('KEY REQUIRED points at Street View when it would draw with the Google key'
|
||||
assert.equal(plain.status.text, 'KEY REQUIRED');
|
||||
assert.notEqual(plain.meta, guidance);
|
||||
});
|
||||
|
||||
test('the caption names an image’s own licence after its author, and leaves it out when there is none', () => {
|
||||
const caption = (street) =>
|
||||
presentStreetLevelPanel({
|
||||
enabled: true,
|
||||
providers: [],
|
||||
coverage: {},
|
||||
legend: [],
|
||||
filter: {},
|
||||
sequence: {},
|
||||
street: { open: true, imageId: '1', ...street },
|
||||
}).viewer.captionLeft;
|
||||
assert.equal(
|
||||
caption({ creator: 'Fixture Mapper', license: 'CC BY-SA 4.0' }),
|
||||
'Image by Fixture Mapper · CC BY-SA 4.0',
|
||||
);
|
||||
assert.equal(
|
||||
caption({ license: 'Licence Ouverte 2.0' }),
|
||||
'Licence Ouverte 2.0',
|
||||
);
|
||||
assert.equal(caption({ creator: 'Someone' }), 'Image by Someone');
|
||||
});
|
||||
@@ -7,6 +7,9 @@
|
||||
/* KartaView's purple for lines; a lighter one where it is text (WCAG AA). */
|
||||
--sl-kartaview: #635bff;
|
||||
--sl-kartaview-text: #8f8aff;
|
||||
/* Panoramax's orange; the darker one sits behind white text. */
|
||||
--sl-panoramax: #ff6f00;
|
||||
--sl-panoramax-fill: #b84e00;
|
||||
--sl-text: 0.62rem;
|
||||
--sl-text-small: 0.6rem;
|
||||
}
|
||||
@@ -646,8 +649,11 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
/* ── KartaView photo viewer ──────────────────────────────────────────────── */
|
||||
.sl-kv-viewer {
|
||||
/* ── Photo viewer (KartaView, Panoramax) ─────────────────────────────────── */
|
||||
.sl-photo-viewer {
|
||||
/* The lit source's colour: a focus ring, and behind the white ‹ › glyphs. */
|
||||
--sl-photo-ring: var(--sl-kartaview-text);
|
||||
--sl-photo-fill: color-mix(in srgb, var(--sl-kartaview) 85%, transparent);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #000;
|
||||
@@ -655,23 +661,29 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sl-kv-viewer:focus-visible {
|
||||
.sl-photo-viewer:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* The photo and canvas fill the viewer and would cover a ring drawn on it:
|
||||
draw the ring on a layer above them (clicks pass through). */
|
||||
.sl-kv-viewer:focus-visible::after {
|
||||
.sl-photo-viewer:focus-visible::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
border: 2px solid var(--sl-kartaview-text);
|
||||
border: 2px solid var(--sl-photo-ring);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sl-kv-photo,
|
||||
.sl-kv-pano {
|
||||
/* White on #ff6f00 is 2.8:1; on the darker orange behind ‹ › it is 5.1:1. */
|
||||
.sl-photo-viewer[data-provider='panoramax'] {
|
||||
--sl-photo-ring: var(--sl-panoramax);
|
||||
--sl-photo-fill: var(--sl-panoramax-fill);
|
||||
}
|
||||
|
||||
.sl-photo-flat,
|
||||
.sl-photo-pano {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
@@ -679,25 +691,25 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sl-kv-photo {
|
||||
.sl-photo-flat {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.sl-kv-pano {
|
||||
.sl-photo-pano {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.sl-kv-pano.sl-kv-dragging {
|
||||
.sl-photo-pano.sl-photo-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.sl-kv-photo[hidden],
|
||||
.sl-kv-pano[hidden] {
|
||||
.sl-photo-flat[hidden],
|
||||
.sl-photo-pano[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sl-kv-step {
|
||||
.sl-photo-step {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
@@ -713,27 +725,28 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sl-kv-step-prev {
|
||||
.sl-photo-step-prev {
|
||||
left: 6px;
|
||||
}
|
||||
|
||||
.sl-kv-step-next {
|
||||
.sl-photo-step-next {
|
||||
right: 6px;
|
||||
}
|
||||
|
||||
.sl-kv-step:hover:not(:disabled),
|
||||
.sl-kv-step:focus-visible {
|
||||
background: color-mix(in srgb, var(--sl-kartaview) 85%, transparent);
|
||||
.sl-photo-step:hover:not(:disabled),
|
||||
.sl-photo-step:focus-visible {
|
||||
background: var(--sl-photo-fill);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sl-kv-step:disabled {
|
||||
.sl-photo-step:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Nothing to step through until a photo shows. */
|
||||
.sl-kv-viewer:not([data-kind='flat']):not([data-kind='pano']) .sl-kv-step {
|
||||
.sl-photo-viewer:not([data-kind='flat']):not([data-kind='pano'])
|
||||
.sl-photo-step {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -349,6 +349,36 @@ html:not([lang='en']) .rail-card-setting {
|
||||
.rail-card-setting .rail-card-chips {
|
||||
margin-top: 0;
|
||||
}
|
||||
.rail-card-setting-range-row {
|
||||
display: flex;
|
||||
grid-column: 2;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
.rail-card-setting-range-label {
|
||||
flex: 0 0 6ch;
|
||||
color: var(--text-dim);
|
||||
font: 8px var(--font-mono);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rail-card-setting-range {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.rail-card-setting-range:focus-visible {
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.rail-card-setting-range-value {
|
||||
flex: 0 0 4ch;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
font: 9px var(--font-mono);
|
||||
text-align: right;
|
||||
}
|
||||
.rail-card-result {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
Reference in new issue
Block a user