diff --git a/public/css/styles.css b/public/css/styles.css index e5d1875..29b186a 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -283,7 +283,7 @@ textarea.input { resize: vertical; } .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; } -.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-sub { color: #64748b; font-size: 0.8rem; margin-top: 0.15rem; } .map-popup-loc { font-size: 0.82rem; margin-top: 0.15rem; } diff --git a/public/js/views/map.js b/public/js/views/map.js index 76ce1e7..cbf8d1c 100644 --- a/public/js/views/map.js +++ b/public/js/views/map.js @@ -4,6 +4,14 @@ import { el } from '../dom.js'; import { typeInfo, entryIcon } from '../format.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) { const route = tctx.route || { stops: [], legs: [], totalKm: 0 }; @@ -91,20 +99,22 @@ function initMap(mapDiv, stops, legs, kmEls) { .bindPopup(popupHtml(stop, info, entryIcon(stop))); }); - // One polyline per measured leg, styled by mode (air = dashed blue, ground = - // solid teal), each with a km label at its midpoint (straight great-circle - // line to start — the map is never empty while road geometry loads). - const legLayers = legs.map(({ leg, a, b }) => { + // One polyline per measured leg, each in its own colour from the shared + // palette (air legs keep the dashed pattern), each with a km label at its + // midpoint (straight great-circle line to start — the map is never empty + // while road geometry loads). + const legLayers = legs.map(({ leg, a, b }, i) => { const air = leg.mode === 'air'; + const color = legColor(i); const line = L.polyline([[a.lat, a.lng], [b.lat, b.lng]], { - color: air ? '#2563eb' : '#0f766e', + color, weight: 3, opacity: 0.75, dashArray: air ? '6 6' : null, }).addTo(map); 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); - return { leg, a, b, line, label }; + const label = L.marker(mid, { icon: kmLabel(L, leg.km, false, color), interactive: false }).addTo(map); + return { leg, a, b, line, label, color }; }); 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 // error UI. Each leg fetches (and fails) independently. 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; api.directions({ lat: a.lat, lng: a.lng }, { lat: b.lat, lng: b.lng }) .then((res) => { // The trip view may have been re-rendered (refreshTrip) while this // 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; line.setLatLngs(res.geometry); 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]; 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({ className: 'km-label-wrap', - html: `${fmtKm(km)} km${road ? ' · road' : ''}`, + html: `${fmtKm(km)} km${road ? ' · road' : ''}`, iconSize: [0, 0], }); } @@ -217,7 +229,10 @@ function legList(legs) { el( 'div', { 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-path' }, stopLabel(a), ' → ', stopLabel(b)), kmEl,