Give each map leg its own colour

Legs cycle through the shared 8-hue palette by index: polylines (air
stays dashed), km-label accents, and the Legs-list number badges all
match, so multiple car routes are distinguishable. Badges use dark text
after a contrast check ruled out white on these hues.
This commit is contained in:
2026-07-20 11:03:11 +07:00
parent 4cedab0cf6
commit 9369c82e56
2 changed files with 29 additions and 14 deletions
+1 -1
View File
@@ -283,7 +283,7 @@ textarea.input { resize: vertical; }
.map-pin-wrap { background: none; border: none; } .map-pin-wrap { background: none; border: none; }
.map-pin { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 50%; color: #fff; font-weight: 700; font-size: 0.8rem; box-shadow: 0 2px 6px rgba(0,0,0,0.35); border: 2px solid #fff; } .map-pin { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 50%; color: #fff; font-weight: 700; font-size: 0.8rem; box-shadow: 0 2px 6px rgba(0,0,0,0.35); border: 2px solid #fff; }
.km-label { background: rgba(37, 99, 235, 0.92); color: #fff; font-size: 0.7rem; font-weight: 700; padding: 0.1rem 0.4rem; border-radius: 999px; white-space: nowrap; box-shadow: 0 1px 3px rgba(0,0,0,0.3); } .km-label { background: rgba(37, 99, 235, 0.92); color: #fff; font-size: 0.7rem; font-weight: 700; padding: 0.1rem 0.4rem 0.1rem 0.55rem; border-radius: 999px; white-space: nowrap; box-shadow: 0 1px 3px rgba(0,0,0,0.3); border-left: 4px solid var(--leg-accent, transparent); }
.map-popup strong { font-size: 0.95rem; } .map-popup strong { font-size: 0.95rem; }
.map-popup-sub { color: #64748b; font-size: 0.8rem; margin-top: 0.15rem; } .map-popup-sub { color: #64748b; font-size: 0.8rem; margin-top: 0.15rem; }
.map-popup-loc { font-size: 0.82rem; margin-top: 0.15rem; } .map-popup-loc { font-size: 0.82rem; margin-top: 0.15rem; }
+28 -13
View File
@@ -4,6 +4,14 @@
import { el } from '../dom.js'; import { el } from '../dom.js';
import { typeInfo, entryIcon } from '../format.js'; import { typeInfo, entryIcon } from '../format.js';
import { api } from '../api.js'; import { api } from '../api.js';
import { STAY_PALETTE } from './stayBands.js';
// Each route leg gets its own colour from the same palette used for stay
// bands/area dots, so several ground legs (e.g. multiple car routes) read
// apart instead of blending into one generic "ground" colour.
function legColor(i) {
return STAY_PALETTE[((i % STAY_PALETTE.length) + STAY_PALETTE.length) % STAY_PALETTE.length];
}
export function renderMap(tctx) { export function renderMap(tctx) {
const route = tctx.route || { stops: [], legs: [], totalKm: 0 }; const route = tctx.route || { stops: [], legs: [], totalKm: 0 };
@@ -91,20 +99,22 @@ function initMap(mapDiv, stops, legs, kmEls) {
.bindPopup(popupHtml(stop, info, entryIcon(stop))); .bindPopup(popupHtml(stop, info, entryIcon(stop)));
}); });
// One polyline per measured leg, styled by mode (air = dashed blue, ground = // One polyline per measured leg, each in its own colour from the shared
// solid teal), each with a km label at its midpoint (straight great-circle // palette (air legs keep the dashed pattern), each with a km label at its
// line to start — the map is never empty while road geometry loads). // midpoint (straight great-circle line to start — the map is never empty
const legLayers = legs.map(({ leg, a, b }) => { // while road geometry loads).
const legLayers = legs.map(({ leg, a, b }, i) => {
const air = leg.mode === 'air'; const air = leg.mode === 'air';
const color = legColor(i);
const line = L.polyline([[a.lat, a.lng], [b.lat, b.lng]], { const line = L.polyline([[a.lat, a.lng], [b.lat, b.lng]], {
color: air ? '#2563eb' : '#0f766e', color,
weight: 3, weight: 3,
opacity: 0.75, opacity: 0.75,
dashArray: air ? '6 6' : null, dashArray: air ? '6 6' : null,
}).addTo(map); }).addTo(map);
const mid = [(a.lat + b.lat) / 2, (a.lng + b.lng) / 2]; const mid = [(a.lat + b.lat) / 2, (a.lng + b.lng) / 2];
const label = L.marker(mid, { icon: kmLabel(L, leg.km), interactive: false }).addTo(map); const label = L.marker(mid, { icon: kmLabel(L, leg.km, false, color), interactive: false }).addTo(map);
return { leg, a, b, line, label }; return { leg, a, b, line, label, color };
}); });
if (points.length === 1) { if (points.length === 1) {
@@ -122,17 +132,18 @@ function initMap(mapDiv, stops, legs, kmEls) {
// already drawn, so a slow/failed fetch just leaves those in place — no // already drawn, so a slow/failed fetch just leaves those in place — no
// error UI. Each leg fetches (and fails) independently. // error UI. Each leg fetches (and fails) independently.
function fetchRoadGeometry(L, map, mapDiv, legLayers, kmEls) { function fetchRoadGeometry(L, map, mapDiv, legLayers, kmEls) {
legLayers.forEach(({ leg, a, b, line, label }, i) => { legLayers.forEach(({ leg, a, b, line, label, color }, i) => {
if (leg.mode !== 'ground') return; if (leg.mode !== 'ground') return;
api.directions({ lat: a.lat, lng: a.lng }, { lat: b.lat, lng: b.lng }) api.directions({ lat: a.lat, lng: a.lng }, { lat: b.lat, lng: b.lng })
.then((res) => { .then((res) => {
// The trip view may have been re-rendered (refreshTrip) while this // The trip view may have been re-rendered (refreshTrip) while this
// was in flight — the old map/section is detached from the DOM and // was in flight — the old map/section is detached from the DOM and
// its layers must not be touched. // its layers must not be touched. Only latlngs/label are swapped —
// the polyline keeps its per-leg colour/style from creation.
if (!mapDiv.isConnected || !map.hasLayer(line) || !map.hasLayer(label)) return; if (!mapDiv.isConnected || !map.hasLayer(line) || !map.hasLayer(label)) return;
line.setLatLngs(res.geometry); line.setLatLngs(res.geometry);
label.setLatLng(pathMidpoint(res.geometry) || label.getLatLng()); label.setLatLng(pathMidpoint(res.geometry) || label.getLatLng());
label.setIcon(kmLabel(L, res.km, true)); label.setIcon(kmLabel(L, res.km, true, color));
const kmEl = kmEls[i]; const kmEl = kmEls[i];
if (kmEl) kmEl.textContent = `${fmtKm(res.km)} km · road`; if (kmEl) kmEl.textContent = `${fmtKm(res.km)} km · road`;
}) })
@@ -166,10 +177,11 @@ function numberedIcon(L, n, color) {
}); });
} }
function kmLabel(L, km, road) { function kmLabel(L, km, road, color) {
const accent = color ? ` style="--leg-accent:${color}"` : '';
return L.divIcon({ return L.divIcon({
className: 'km-label-wrap', className: 'km-label-wrap',
html: `<span class="km-label">${fmtKm(km)} km${road ? ' · road' : ''}</span>`, html: `<span class="km-label"${accent}>${fmtKm(km)} km${road ? ' · road' : ''}</span>`,
iconSize: [0, 0], iconSize: [0, 0],
}); });
} }
@@ -217,7 +229,10 @@ function legList(legs) {
el( el(
'div', 'div',
{ class: 'leg-row' }, { class: 'leg-row' },
el('span', { class: 'leg-index' }, String(i + 1)), // Dark text, not white: the palette's mid-tone hues (amber, lime, …)
// don't clear AA contrast with white at full strength — the same
// reason stay bands use dark text on these colours (see stayBands.js).
el('span', { class: 'leg-index', style: { background: legColor(i), color: '#334155' } }, String(i + 1)),
el('span', { class: 'leg-mode', title: air ? 'Flight' : 'Ground' }, air ? '✈️' : '🚗'), el('span', { class: 'leg-mode', title: air ? 'Flight' : 'Ground' }, air ? '✈️' : '🚗'),
el('span', { class: 'leg-path' }, stopLabel(a), ' → ', stopLabel(b)), el('span', { class: 'leg-path' }, stopLabel(a), ' → ', stopLabel(b)),
kmEl, kmEl,