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:
German KhokhlovandClaude Opus 5.5 committed 2026-10-16 00:06:45 +04:00
commit 7fd1dd867f
77 files changed
+9201 -919

No files matched your search

+4
View File
@@ -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
View File
@@ -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);
+90
View File
@@ -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++);
+5
View File
@@ -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,
+22
View File
@@ -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');
});
+33 -20
View File
@@ -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;
}
+30
View File
@@ -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;