summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-11 22:19:22 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-11 22:19:22 +0200
commit991ab2f179b2d84e8b0ce6d0634b4033f20e4b86 (patch)
tree225d8b5e8b46245ece9f2d8002b7db47727c3ee3 /web
parent690ced7aea43c1187a66d9f7ef813bd6daa13195 (diff)
refactor(web): extract ride detail script
Diffstat (limited to 'web')
-rw-r--r--web/server_test.go21
-rw-r--r--web/static/ride-detail.js245
-rw-r--r--web/templates.templ250
-rw-r--r--web/templates_templ.go10
4 files changed, 268 insertions, 258 deletions
diff --git a/web/server_test.go b/web/server_test.go
index 8e4c568..23270ed 100644
--- a/web/server_test.go
+++ b/web/server_test.go
@@ -161,12 +161,23 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) {
assert.Contains(t, body, `id="ride-profile"`)
assert.Contains(t, body, `id="ride-profile-chart"`)
assert.Contains(t, body, `id="ride-map"`)
- assert.Contains(t, body, "pointermove")
- assert.Contains(t, body, "circleMarker")
- assert.Contains(t, body, "crossing.passElevationM")
- assert.Contains(t, body, "const labelY = plot.top - 6")
+ assert.Contains(t, body, `<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" defer></script>`)
+ assert.Contains(t, body, `<script src="/static/ride-detail.js" defer></script>`)
+ assert.NotContains(t, body, "pointermove")
+
+ staticRequest := httptest.NewRequest(http.MethodGet, "/static/ride-detail.js", nil)
+ staticResponse := httptest.NewRecorder()
+ server.Handler().ServeHTTP(staticResponse, staticRequest)
+
+ script := staticResponse.Body.String()
+ assert.Equal(t, http.StatusOK, staticResponse.Code)
+ assert.Equal(t, "text/javascript; charset=utf-8", staticResponse.Header().Get("Content-Type"))
+ assert.Contains(t, script, "pointermove")
+ assert.Contains(t, script, "circleMarker")
+ assert.Contains(t, script, "crossing.passElevationM")
+ assert.Contains(t, script, "const labelY = plot.top - 6")
assert.Contains(t, body, "leaflet@1.9.4/dist/leaflet.js")
- assert.Contains(t, body, "tile.openstreetmap.org/{z}/{x}/{y}.png")
+ assert.Contains(t, script, "tile.openstreetmap.org/{z}/{x}/{y}.png")
assert.Contains(t, body, `"type":"FeatureCollection"`)
assert.Contains(t, body, `"type":"LineString"`)
assert.Contains(t, body, `"coordinates":[[5,43]`)
diff --git a/web/static/ride-detail.js b/web/static/ride-detail.js
new file mode 100644
index 0000000..178b486
--- /dev/null
+++ b/web/static/ride-detail.js
@@ -0,0 +1,245 @@
+(() => {
+ const mapElement = document.getElementById("ride-map");
+ const routeElement = document.getElementById("ride-route");
+ const profileElement = document.getElementById("ride-profile");
+ const canvas = document.getElementById("ride-profile-chart");
+ const hoverOutput = document.getElementById("ride-profile-hover");
+ if (!mapElement || !routeElement || !profileElement || !canvas || !hoverOutput) return;
+ const colorProbe = document.createElement("span");
+ colorProbe.hidden = true;
+ document.body.append(colorProbe);
+ const resolveColor = (name) => {
+ colorProbe.style.color = `var(${name})`;
+ return getComputedStyle(colorProbe).color;
+ };
+ const colors = {
+ accent: resolveColor("--color-accent"),
+ forest: resolveColor("--color-forest"),
+ subtle: resolveColor("--color-subtle"),
+ plotSurface: resolveColor("--color-plot-surface"),
+ plotSurfaceOverlay: resolveColor("--color-plot-surface-overlay"),
+ grid: resolveColor("--color-plot-grid"),
+ accentFill: resolveColor("--color-accent-fill"),
+ climbLabel: resolveColor("--color-climb-label"),
+ crossing: resolveColor("--color-crossing"),
+ crossingLabel: resolveColor("--color-crossing-label"),
+ hoverLine: resolveColor("--color-hover-line"),
+ };
+ colorProbe.remove();
+ const route = JSON.parse(routeElement.textContent);
+ const profile = JSON.parse(profileElement.textContent);
+ const map = L.map(mapElement);
+ const tiles = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
+ maxZoom: 19,
+ attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
+ });
+ tiles.addTo(map);
+ const routeLayer = L.geoJSON(route, {
+ style: { color: colors.accent, weight: 4, opacity: 0.9 },
+ }).addTo(map);
+ const bounds = routeLayer.getBounds();
+ if (bounds.isValid()) {
+ map.fitBounds(bounds, { padding: [24, 24], maxZoom: 15 });
+ }
+
+ const points = profile.points || [];
+ if (points.length === 0) return;
+ const routeCursor = L.circleMarker([points[0].latitude, points[0].longitude], {
+ color: colors.forest,
+ fillColor: colors.accent,
+ fillOpacity: 0,
+ opacity: 0,
+ radius: 7,
+ weight: 3,
+ interactive: false,
+ }).addTo(map);
+
+ const context = canvas.getContext("2d");
+ if (!context) return;
+ const state = { hoveredIndex: -1, plot: null, width: 0, height: 0 };
+ const minDistance = points[0].distanceKm;
+ const maxDistance = points[points.length - 1].distanceKm;
+ let minElevation = points[0].elevationM;
+ let maxElevation = points[0].elevationM;
+ for (const point of points) {
+ minElevation = Math.min(minElevation, point.elevationM);
+ maxElevation = Math.max(maxElevation, point.elevationM);
+ }
+ const elevationPadding = Math.max((maxElevation - minElevation) * 0.1, 20);
+ minElevation -= elevationPadding;
+ maxElevation += elevationPadding;
+
+ const formatDistance = (distance) => `${distance.toFixed(distance < 10 ? 1 : 0)} km`;
+ const formatElevation = (elevation) => `${Math.round(elevation)} m`;
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
+ const xForDistance = (distance) => {
+ const span = Math.max(maxDistance - minDistance, 1);
+ return state.plot.left + (distance - minDistance) / span * (state.plot.right - state.plot.left);
+ };
+ const yForElevation = (elevation) => {
+ const span = Math.max(maxElevation - minElevation, 1);
+ return state.plot.bottom - (elevation - minElevation) / span * (state.plot.bottom - state.plot.top);
+ };
+ const nearestPointIndex = (distance) => {
+ let low = 0;
+ let high = points.length - 1;
+ while (low < high) {
+ const middle = Math.floor((low + high) / 2);
+ if (points[middle].distanceKm < distance) low = middle + 1;
+ else high = middle;
+ }
+ if (low === 0) return low;
+ const previous = points[low - 1];
+ return distance - previous.distanceKm < points[low].distanceKm - distance ? low - 1 : low;
+ };
+
+ const draw = () => {
+ const rect = canvas.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) return;
+ const ratio = window.devicePixelRatio || 1;
+ canvas.width = Math.floor(rect.width * ratio);
+ canvas.height = Math.floor(rect.height * ratio);
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
+ state.width = rect.width;
+ state.height = rect.height;
+ state.plot = { left: 52, right: rect.width - 20, top: 24, bottom: rect.height - 34 };
+ const plot = state.plot;
+ context.clearRect(0, 0, rect.width, rect.height);
+ context.fillStyle = colors.plotSurface;
+ context.fillRect(0, 0, rect.width, rect.height);
+
+ context.font = "12px system-ui, sans-serif";
+ context.textBaseline = "middle";
+ for (let step = 0; step <= 4; step++) {
+ const fraction = step / 4;
+ const y = plot.top + fraction * (plot.bottom - plot.top);
+ const elevation = maxElevation - fraction * (maxElevation - minElevation);
+ context.strokeStyle = colors.grid;
+ context.lineWidth = 1;
+ context.beginPath();
+ context.moveTo(plot.left, y);
+ context.lineTo(plot.right, y);
+ context.stroke();
+ context.fillStyle = colors.subtle;
+ context.textAlign = "right";
+ context.fillText(formatElevation(elevation), plot.left - 8, y);
+ }
+
+ for (const climb of profile.climbs || []) {
+ const startX = clamp(xForDistance(climb.startKm), plot.left, plot.right);
+ const endX = clamp(xForDistance(climb.endKm), plot.left, plot.right);
+ context.fillStyle = colors.accentFill;
+ context.fillRect(startX, plot.top, Math.max(endX - startX, 1), plot.bottom - plot.top);
+ const label = climb.name || `${climb.category} ${Math.round(climb.score)}`;
+ context.fillStyle = colors.climbLabel;
+ context.textAlign = "center";
+ context.textBaseline = "top";
+ context.fillText(label, clamp((startX + endX) / 2, plot.left + 24, plot.right - 24), plot.top + 4);
+ }
+
+ for (const crossing of profile.crossings || []) {
+ const x = xForDistance(crossing.distanceKm);
+ context.strokeStyle = colors.crossing;
+ context.lineWidth = 1;
+ context.setLineDash([4, 3]);
+ context.beginPath();
+ context.moveTo(x, plot.top);
+ context.lineTo(x, plot.bottom);
+ context.stroke();
+ context.setLineDash([]);
+ }
+
+ context.beginPath();
+ context.moveTo(xForDistance(points[0].distanceKm), plot.bottom);
+ for (const point of points) context.lineTo(xForDistance(point.distanceKm), yForElevation(point.elevationM));
+ context.lineTo(xForDistance(points[points.length - 1].distanceKm), plot.bottom);
+ context.closePath();
+ context.fillStyle = colors.accentFill;
+ context.fill();
+ context.beginPath();
+ for (let i = 0; i < points.length; i++) {
+ const point = points[i];
+ if (i === 0) context.moveTo(xForDistance(point.distanceKm), yForElevation(point.elevationM));
+ else context.lineTo(xForDistance(point.distanceKm), yForElevation(point.elevationM));
+ }
+ context.strokeStyle = colors.accent;
+ context.lineWidth = 2.5;
+ context.stroke();
+ for (const crossing of profile.crossings || []) {
+ const x = xForDistance(crossing.distanceKm);
+ const label = `${crossing.name} ${Math.round(crossing.passElevationM)} m`;
+ const labelWidth = context.measureText(label).width;
+ const labelX = clamp(x, plot.left + labelWidth / 2 + 3, plot.right - labelWidth / 2 - 3);
+ const labelY = plot.top - 6;
+ context.fillStyle = colors.plotSurfaceOverlay;
+ context.fillRect(labelX - labelWidth / 2 - 3, labelY - 15, labelWidth + 6, 16);
+ context.fillStyle = colors.crossingLabel;
+ context.textAlign = "center";
+ context.textBaseline = "bottom";
+ context.fillText(label, labelX, labelY);
+ }
+
+ context.fillStyle = colors.subtle;
+ context.textAlign = "center";
+ context.textBaseline = "top";
+ for (let step = 0; step <= 4; step++) {
+ const distance = minDistance + step / 4 * (maxDistance - minDistance);
+ context.fillText(formatDistance(distance), xForDistance(distance), plot.bottom + 10);
+ }
+
+ if (state.hoveredIndex >= 0) {
+ const point = points[state.hoveredIndex];
+ const x = xForDistance(point.distanceKm);
+ const y = yForElevation(point.elevationM);
+ context.strokeStyle = colors.hoverLine;
+ context.lineWidth = 1;
+ context.setLineDash([3, 3]);
+ context.beginPath();
+ context.moveTo(x, plot.top);
+ context.lineTo(x, plot.bottom);
+ context.stroke();
+ context.setLineDash([]);
+ context.fillStyle = colors.forest;
+ context.beginPath();
+ context.arc(x, y, 5, 0, 2 * Math.PI);
+ context.fill();
+ }
+ };
+
+ const clearHover = () => {
+ state.hoveredIndex = -1;
+ routeCursor.setStyle({ opacity: 0, fillOpacity: 0 });
+ hoverOutput.textContent = "Hover or focus the profile to inspect elevation.";
+ draw();
+ };
+ const showPoint = (index) => {
+ state.hoveredIndex = clamp(index, 0, points.length - 1);
+ const point = points[state.hoveredIndex];
+ routeCursor.setLatLng([point.latitude, point.longitude]);
+ routeCursor.setStyle({ opacity: 1, fillOpacity: 0.9 });
+ hoverOutput.textContent = `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`;
+ draw();
+ };
+ canvas.addEventListener("pointermove", (event) => {
+ if (!state.plot) return;
+ const rect = canvas.getBoundingClientRect();
+ const x = event.clientX - rect.left;
+ if (x < state.plot.left || x > state.plot.right) {
+ clearHover();
+ return;
+ }
+ const distance = minDistance + (x - state.plot.left) / (state.plot.right - state.plot.left) * (maxDistance - minDistance);
+ showPoint(nearestPointIndex(distance));
+ });
+ canvas.addEventListener("pointerleave", clearHover);
+ canvas.addEventListener("pointercancel", clearHover);
+ canvas.addEventListener("keydown", (event) => {
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
+ event.preventDefault();
+ const direction = event.key === "ArrowRight" ? 1 : -1;
+ const index = state.hoveredIndex < 0 ? (direction > 0 ? 0 : points.length - 1) : state.hoveredIndex + direction;
+ showPoint(index);
+ });
+ window.addEventListener("resize", draw);
+ draw();
+})();
diff --git a/web/templates.templ b/web/templates.templ
index 8e7c0f1..515c482 100644
--- a/web/templates.templ
+++ b/web/templates.templ
@@ -88,254 +88,8 @@ templ RideDetailContent(data RideDetailView) {
</section>
@templ.JSONScript("ride-route", data.Route)
@templ.JSONScript("ride-profile", data.Profile)
- <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
- <script>
- (() => {
- const mapElement = document.getElementById("ride-map");
- const routeElement = document.getElementById("ride-route");
- const profileElement = document.getElementById("ride-profile");
- const canvas = document.getElementById("ride-profile-chart");
- const hoverOutput = document.getElementById("ride-profile-hover");
- if (!mapElement || !routeElement || !profileElement || !canvas || !hoverOutput) return;
- const colorProbe = document.createElement("span");
- colorProbe.hidden = true;
- document.body.append(colorProbe);
- const resolveColor = (name) => {
- colorProbe.style.color = `var(${name})`;
- return getComputedStyle(colorProbe).color;
- };
- const colors = {
- accent: resolveColor("--color-accent"),
- forest: resolveColor("--color-forest"),
- subtle: resolveColor("--color-subtle"),
- plotSurface: resolveColor("--color-plot-surface"),
- plotSurfaceOverlay: resolveColor("--color-plot-surface-overlay"),
- grid: resolveColor("--color-plot-grid"),
- accentFill: resolveColor("--color-accent-fill"),
- climbLabel: resolveColor("--color-climb-label"),
- crossing: resolveColor("--color-crossing"),
- crossingLabel: resolveColor("--color-crossing-label"),
- hoverLine: resolveColor("--color-hover-line"),
- };
- colorProbe.remove();
- const route = JSON.parse(routeElement.textContent);
- const profile = JSON.parse(profileElement.textContent);
- const map = L.map(mapElement);
- const tiles = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
- maxZoom: 19,
- attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
- });
- tiles.addTo(map);
- const routeLayer = L.geoJSON(route, {
- style: { color: colors.accent, weight: 4, opacity: 0.9 },
- }).addTo(map);
- const bounds = routeLayer.getBounds();
- if (bounds.isValid()) {
- map.fitBounds(bounds, { padding: [24, 24], maxZoom: 15 });
- }
-
- const points = profile.points || [];
- if (points.length === 0) return;
- const routeCursor = L.circleMarker([points[0].latitude, points[0].longitude], {
- color: colors.forest,
- fillColor: colors.accent,
- fillOpacity: 0,
- opacity: 0,
- radius: 7,
- weight: 3,
- interactive: false,
- }).addTo(map);
-
- const context = canvas.getContext("2d");
- if (!context) return;
- const state = { hoveredIndex: -1, plot: null, width: 0, height: 0 };
- const minDistance = points[0].distanceKm;
- const maxDistance = points[points.length - 1].distanceKm;
- let minElevation = points[0].elevationM;
- let maxElevation = points[0].elevationM;
- for (const point of points) {
- minElevation = Math.min(minElevation, point.elevationM);
- maxElevation = Math.max(maxElevation, point.elevationM);
- }
- const elevationPadding = Math.max((maxElevation - minElevation) * 0.1, 20);
- minElevation -= elevationPadding;
- maxElevation += elevationPadding;
-
- const formatDistance = (distance) => `${distance.toFixed(distance < 10 ? 1 : 0)} km`;
- const formatElevation = (elevation) => `${Math.round(elevation)} m`;
- const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
- const xForDistance = (distance) => {
- const span = Math.max(maxDistance - minDistance, 1);
- return state.plot.left + (distance - minDistance) / span * (state.plot.right - state.plot.left);
- };
- const yForElevation = (elevation) => {
- const span = Math.max(maxElevation - minElevation, 1);
- return state.plot.bottom - (elevation - minElevation) / span * (state.plot.bottom - state.plot.top);
- };
- const nearestPointIndex = (distance) => {
- let low = 0;
- let high = points.length - 1;
- while (low < high) {
- const middle = Math.floor((low + high) / 2);
- if (points[middle].distanceKm < distance) low = middle + 1;
- else high = middle;
- }
- if (low === 0) return low;
- const previous = points[low - 1];
- return distance - previous.distanceKm < points[low].distanceKm - distance ? low - 1 : low;
- };
-
- const draw = () => {
- const rect = canvas.getBoundingClientRect();
- if (rect.width === 0 || rect.height === 0) return;
- const ratio = window.devicePixelRatio || 1;
- canvas.width = Math.floor(rect.width * ratio);
- canvas.height = Math.floor(rect.height * ratio);
- context.setTransform(ratio, 0, 0, ratio, 0, 0);
- state.width = rect.width;
- state.height = rect.height;
- state.plot = { left: 52, right: rect.width - 20, top: 24, bottom: rect.height - 34 };
- const plot = state.plot;
- context.clearRect(0, 0, rect.width, rect.height);
- context.fillStyle = colors.plotSurface;
- context.fillRect(0, 0, rect.width, rect.height);
-
- context.font = "12px system-ui, sans-serif";
- context.textBaseline = "middle";
- for (let step = 0; step <= 4; step++) {
- const fraction = step / 4;
- const y = plot.top + fraction * (plot.bottom - plot.top);
- const elevation = maxElevation - fraction * (maxElevation - minElevation);
- context.strokeStyle = colors.grid;
- context.lineWidth = 1;
- context.beginPath();
- context.moveTo(plot.left, y);
- context.lineTo(plot.right, y);
- context.stroke();
- context.fillStyle = colors.subtle;
- context.textAlign = "right";
- context.fillText(formatElevation(elevation), plot.left - 8, y);
- }
-
- for (const climb of profile.climbs || []) {
- const startX = clamp(xForDistance(climb.startKm), plot.left, plot.right);
- const endX = clamp(xForDistance(climb.endKm), plot.left, plot.right);
- context.fillStyle = colors.accentFill;
- context.fillRect(startX, plot.top, Math.max(endX - startX, 1), plot.bottom - plot.top);
- const label = climb.name || `${climb.category} ${Math.round(climb.score)}`;
- context.fillStyle = colors.climbLabel;
- context.textAlign = "center";
- context.textBaseline = "top";
- context.fillText(label, clamp((startX + endX) / 2, plot.left + 24, plot.right - 24), plot.top + 4);
- }
-
- for (const crossing of profile.crossings || []) {
- const x = xForDistance(crossing.distanceKm);
- context.strokeStyle = colors.crossing;
- context.lineWidth = 1;
- context.setLineDash([4, 3]);
- context.beginPath();
- context.moveTo(x, plot.top);
- context.lineTo(x, plot.bottom);
- context.stroke();
- context.setLineDash([]);
- }
-
- context.beginPath();
- context.moveTo(xForDistance(points[0].distanceKm), plot.bottom);
- for (const point of points) context.lineTo(xForDistance(point.distanceKm), yForElevation(point.elevationM));
- context.lineTo(xForDistance(points[points.length - 1].distanceKm), plot.bottom);
- context.closePath();
- context.fillStyle = colors.accentFill;
- context.fill();
- context.beginPath();
- for (let i = 0; i < points.length; i++) {
- const point = points[i];
- if (i === 0) context.moveTo(xForDistance(point.distanceKm), yForElevation(point.elevationM));
- else context.lineTo(xForDistance(point.distanceKm), yForElevation(point.elevationM));
- }
- context.strokeStyle = colors.accent;
- context.lineWidth = 2.5;
- context.stroke();
- for (const crossing of profile.crossings || []) {
- const x = xForDistance(crossing.distanceKm);
- const label = `${crossing.name} ${Math.round(crossing.passElevationM)} m`;
- const labelWidth = context.measureText(label).width;
- const labelX = clamp(x, plot.left + labelWidth / 2 + 3, plot.right - labelWidth / 2 - 3);
- const labelY = plot.top - 6;
- context.fillStyle = colors.plotSurfaceOverlay;
- context.fillRect(labelX - labelWidth / 2 - 3, labelY - 15, labelWidth + 6, 16);
- context.fillStyle = colors.crossingLabel;
- context.textAlign = "center";
- context.textBaseline = "bottom";
- context.fillText(label, labelX, labelY);
- }
-
- context.fillStyle = colors.subtle;
- context.textAlign = "center";
- context.textBaseline = "top";
- for (let step = 0; step <= 4; step++) {
- const distance = minDistance + step / 4 * (maxDistance - minDistance);
- context.fillText(formatDistance(distance), xForDistance(distance), plot.bottom + 10);
- }
-
- if (state.hoveredIndex >= 0) {
- const point = points[state.hoveredIndex];
- const x = xForDistance(point.distanceKm);
- const y = yForElevation(point.elevationM);
- context.strokeStyle = colors.hoverLine;
- context.lineWidth = 1;
- context.setLineDash([3, 3]);
- context.beginPath();
- context.moveTo(x, plot.top);
- context.lineTo(x, plot.bottom);
- context.stroke();
- context.setLineDash([]);
- context.fillStyle = colors.forest;
- context.beginPath();
- context.arc(x, y, 5, 0, 2 * Math.PI);
- context.fill();
- }
- };
-
- const clearHover = () => {
- state.hoveredIndex = -1;
- routeCursor.setStyle({ opacity: 0, fillOpacity: 0 });
- hoverOutput.textContent = "Hover or focus the profile to inspect elevation.";
- draw();
- };
- const showPoint = (index) => {
- state.hoveredIndex = clamp(index, 0, points.length - 1);
- const point = points[state.hoveredIndex];
- routeCursor.setLatLng([point.latitude, point.longitude]);
- routeCursor.setStyle({ opacity: 1, fillOpacity: 0.9 });
- hoverOutput.textContent = `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`;
- draw();
- };
- canvas.addEventListener("pointermove", (event) => {
- if (!state.plot) return;
- const rect = canvas.getBoundingClientRect();
- const x = event.clientX - rect.left;
- if (x < state.plot.left || x > state.plot.right) {
- clearHover();
- return;
- }
- const distance = minDistance + (x - state.plot.left) / (state.plot.right - state.plot.left) * (maxDistance - minDistance);
- showPoint(nearestPointIndex(distance));
- });
- canvas.addEventListener("pointerleave", clearHover);
- canvas.addEventListener("pointercancel", clearHover);
- canvas.addEventListener("keydown", (event) => {
- if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
- event.preventDefault();
- const direction = event.key === "ArrowRight" ? 1 : -1;
- const index = state.hoveredIndex < 0 ? (direction > 0 ? 0 : points.length - 1) : state.hoveredIndex + direction;
- showPoint(index);
- });
- window.addEventListener("resize", draw);
- draw();
- })();
- </script>
+ <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" defer></script>
+ <script src="/static/ride-detail.js" defer></script>
}
}
diff --git a/web/templates_templ.go b/web/templates_templ.go
index 6991c73..021de0b 100644
--- a/web/templates_templ.go
+++ b/web/templates_templ.go
@@ -498,7 +498,7 @@ func RideDetailContent(data RideDetailView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" <script src=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\"></script> <script>\n\t\t\t(() => {\n\t\t\t\tconst mapElement = document.getElementById(\"ride-map\");\n\t\t\t\tconst routeElement = document.getElementById(\"ride-route\");\n\t\t\t\tconst profileElement = document.getElementById(\"ride-profile\");\n\t\t\t\tconst canvas = document.getElementById(\"ride-profile-chart\");\n\t\t\t\tconst hoverOutput = document.getElementById(\"ride-profile-hover\");\n\t\t\t\tif (!mapElement || !routeElement || !profileElement || !canvas || !hoverOutput) return;\n\t\t\t\tconst colorProbe = document.createElement(\"span\");\n\t\t\t\tcolorProbe.hidden = true;\n\t\t\t\tdocument.body.append(colorProbe);\n\t\t\t\tconst resolveColor = (name) => {\n\t\t\t\t\tcolorProbe.style.color = `var(${name})`;\n\t\t\t\t\treturn getComputedStyle(colorProbe).color;\n\t\t\t\t};\n\t\t\t\tconst colors = {\n\t\t\t\t\taccent: resolveColor(\"--color-accent\"),\n\t\t\t\t\tforest: resolveColor(\"--color-forest\"),\n\t\t\t\t\tsubtle: resolveColor(\"--color-subtle\"),\n\t\t\t\t\tplotSurface: resolveColor(\"--color-plot-surface\"),\n\t\t\t\t\tplotSurfaceOverlay: resolveColor(\"--color-plot-surface-overlay\"),\n\t\t\t\t\tgrid: resolveColor(\"--color-plot-grid\"),\n\t\t\t\t\taccentFill: resolveColor(\"--color-accent-fill\"),\n\t\t\t\t\tclimbLabel: resolveColor(\"--color-climb-label\"),\n\t\t\t\t\tcrossing: resolveColor(\"--color-crossing\"),\n\t\t\t\t\tcrossingLabel: resolveColor(\"--color-crossing-label\"),\n\t\t\t\t\thoverLine: resolveColor(\"--color-hover-line\"),\n\t\t\t\t};\n\t\t\t\tcolorProbe.remove();\n\t\t\t\tconst route = JSON.parse(routeElement.textContent);\n\t\t\t\tconst profile = JSON.parse(profileElement.textContent);\n\t\t\t\tconst map = L.map(mapElement);\n\t\t\t\tconst tiles = L.tileLayer(\"https://tile.openstreetmap.org/{z}/{x}/{y}.png\", {\n\t\t\t\t\tmaxZoom: 19,\n\t\t\t\t\tattribution: '&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors',\n\t\t\t\t});\n\t\t\t\ttiles.addTo(map);\n\t\t\t\tconst routeLayer = L.geoJSON(route, {\n\t\t\t\t\tstyle: { color: colors.accent, weight: 4, opacity: 0.9 },\n\t\t\t\t}).addTo(map);\n\t\t\t\tconst bounds = routeLayer.getBounds();\n\t\t\t\tif (bounds.isValid()) {\n\t\t\t\t\tmap.fitBounds(bounds, { padding: [24, 24], maxZoom: 15 });\n\t\t\t\t}\n\n\t\t\t\tconst points = profile.points || [];\n\t\t\t\tif (points.length === 0) return;\n\t\t\t\tconst routeCursor = L.circleMarker([points[0].latitude, points[0].longitude], {\n\t\t\t\t\tcolor: colors.forest,\n\t\t\t\t\tfillColor: colors.accent,\n\t\t\t\t\tfillOpacity: 0,\n\t\t\t\t\topacity: 0,\n\t\t\t\t\tradius: 7,\n\t\t\t\t\tweight: 3,\n\t\t\t\t\tinteractive: false,\n\t\t\t\t}).addTo(map);\n\n\t\t\t\tconst context = canvas.getContext(\"2d\");\n\t\t\t\tif (!context) return;\n\t\t\t\tconst state = { hoveredIndex: -1, plot: null, width: 0, height: 0 };\n\t\t\t\tconst minDistance = points[0].distanceKm;\n\t\t\t\tconst maxDistance = points[points.length - 1].distanceKm;\n\t\t\t\tlet minElevation = points[0].elevationM;\n\t\t\t\tlet maxElevation = points[0].elevationM;\n\t\t\t\tfor (const point of points) {\n\t\t\t\t\tminElevation = Math.min(minElevation, point.elevationM);\n\t\t\t\t\tmaxElevation = Math.max(maxElevation, point.elevationM);\n\t\t\t\t}\n\t\t\t\tconst elevationPadding = Math.max((maxElevation - minElevation) * 0.1, 20);\n\t\t\t\tminElevation -= elevationPadding;\n\t\t\t\tmaxElevation += elevationPadding;\n\n\t\t\t\tconst formatDistance = (distance) => `${distance.toFixed(distance < 10 ? 1 : 0)} km`;\n\t\t\t\tconst formatElevation = (elevation) => `${Math.round(elevation)} m`;\n\t\t\t\tconst clamp = (value, min, max) => Math.max(min, Math.min(max, value));\n\t\t\t\tconst xForDistance = (distance) => {\n\t\t\t\t\tconst span = Math.max(maxDistance - minDistance, 1);\n\t\t\t\t\treturn state.plot.left + (distance - minDistance) / span * (state.plot.right - state.plot.left);\n\t\t\t\t};\n\t\t\t\tconst yForElevation = (elevation) => {\n\t\t\t\t\tconst span = Math.max(maxElevation - minElevation, 1);\n\t\t\t\t\treturn state.plot.bottom - (elevation - minElevation) / span * (state.plot.bottom - state.plot.top);\n\t\t\t\t};\n\t\t\t\tconst nearestPointIndex = (distance) => {\n\t\t\t\t\tlet low = 0;\n\t\t\t\t\tlet high = points.length - 1;\n\t\t\t\t\twhile (low < high) {\n\t\t\t\t\t\tconst middle = Math.floor((low + high) / 2);\n\t\t\t\t\t\tif (points[middle].distanceKm < distance) low = middle + 1;\n\t\t\t\t\t\telse high = middle;\n\t\t\t\t\t}\n\t\t\t\t\tif (low === 0) return low;\n\t\t\t\t\tconst previous = points[low - 1];\n\t\t\t\t\treturn distance - previous.distanceKm < points[low].distanceKm - distance ? low - 1 : low;\n\t\t\t\t};\n\n\t\t\t\tconst draw = () => {\n\t\t\t\t\tconst rect = canvas.getBoundingClientRect();\n\t\t\t\t\tif (rect.width === 0 || rect.height === 0) return;\n\t\t\t\t\tconst ratio = window.devicePixelRatio || 1;\n\t\t\t\t\tcanvas.width = Math.floor(rect.width * ratio);\n\t\t\t\t\tcanvas.height = Math.floor(rect.height * ratio);\n\t\t\t\t\tcontext.setTransform(ratio, 0, 0, ratio, 0, 0);\n\t\t\t\t\tstate.width = rect.width;\n\t\t\t\t\tstate.height = rect.height;\n\t\t\t\t\tstate.plot = { left: 52, right: rect.width - 20, top: 24, bottom: rect.height - 34 };\n\t\t\t\t\tconst plot = state.plot;\n\t\t\t\t\tcontext.clearRect(0, 0, rect.width, rect.height);\n\t\t\t\t\tcontext.fillStyle = colors.plotSurface;\n\t\t\t\t\tcontext.fillRect(0, 0, rect.width, rect.height);\n\n\t\t\t\t\tcontext.font = \"12px system-ui, sans-serif\";\n\t\t\t\t\tcontext.textBaseline = \"middle\";\n\t\t\t\t\tfor (let step = 0; step <= 4; step++) {\n\t\t\t\t\t\tconst fraction = step / 4;\n\t\t\t\t\t\tconst y = plot.top + fraction * (plot.bottom - plot.top);\n\t\t\t\t\t\tconst elevation = maxElevation - fraction * (maxElevation - minElevation);\n\t\t\t\t\t\tcontext.strokeStyle = colors.grid;\n\t\t\t\t\t\tcontext.lineWidth = 1;\n\t\t\t\t\t\tcontext.beginPath();\n\t\t\t\t\t\tcontext.moveTo(plot.left, y);\n\t\t\t\t\t\tcontext.lineTo(plot.right, y);\n\t\t\t\t\t\tcontext.stroke();\n\t\t\t\t\t\tcontext.fillStyle = colors.subtle;\n\t\t\t\t\t\tcontext.textAlign = \"right\";\n\t\t\t\t\t\tcontext.fillText(formatElevation(elevation), plot.left - 8, y);\n\t\t\t\t\t}\n\n\t\t\t\t\tfor (const climb of profile.climbs || []) {\n\t\t\t\t\t\tconst startX = clamp(xForDistance(climb.startKm), plot.left, plot.right);\n\t\t\t\t\t\tconst endX = clamp(xForDistance(climb.endKm), plot.left, plot.right);\n\t\t\t\t\t\tcontext.fillStyle = colors.accentFill;\n\t\t\t\t\t\tcontext.fillRect(startX, plot.top, Math.max(endX - startX, 1), plot.bottom - plot.top);\n\t\t\t\t\t\tconst label = climb.name || `${climb.category} ${Math.round(climb.score)}`;\n\t\t\t\t\t\tcontext.fillStyle = colors.climbLabel;\n\t\t\t\t\t\tcontext.textAlign = \"center\";\n\t\t\t\t\t\tcontext.textBaseline = \"top\";\n\t\t\t\t\t\tcontext.fillText(label, clamp((startX + endX) / 2, plot.left + 24, plot.right - 24), plot.top + 4);\n\t\t\t\t\t}\n\n\t\t\t\t\tfor (const crossing of profile.crossings || []) {\n\t\t\t\t\t\tconst x = xForDistance(crossing.distanceKm);\n\t\t\t\t\t\tcontext.strokeStyle = colors.crossing;\n\t\t\t\t\t\tcontext.lineWidth = 1;\n\t\t\t\t\t\tcontext.setLineDash([4, 3]);\n\t\t\t\t\t\tcontext.beginPath();\n\t\t\t\t\t\tcontext.moveTo(x, plot.top);\n\t\t\t\t\t\tcontext.lineTo(x, plot.bottom);\n\t\t\t\t\t\tcontext.stroke();\n\t\t\t\t\t\tcontext.setLineDash([]);\n\t\t\t\t\t}\n\n\t\t\t\t\tcontext.beginPath();\n\t\t\t\t\tcontext.moveTo(xForDistance(points[0].distanceKm), plot.bottom);\n\t\t\t\t\tfor (const point of points) context.lineTo(xForDistance(point.distanceKm), yForElevation(point.elevationM));\n\t\t\t\t\tcontext.lineTo(xForDistance(points[points.length - 1].distanceKm), plot.bottom);\n\t\t\t\t\tcontext.closePath();\n\t\t\t\t\tcontext.fillStyle = colors.accentFill;\n\t\t\t\t\tcontext.fill();\n\t\t\t\t\tcontext.beginPath();\n\t\t\t\t\tfor (let i = 0; i < points.length; i++) {\n\t\t\t\t\t\tconst point = points[i];\n\t\t\t\t\t\tif (i === 0) context.moveTo(xForDistance(point.distanceKm), yForElevation(point.elevationM));\n\t\t\t\t\t\telse context.lineTo(xForDistance(point.distanceKm), yForElevation(point.elevationM));\n\t\t\t\t\t}\n\t\t\t\t\tcontext.strokeStyle = colors.accent;\n\t\t\t\t\tcontext.lineWidth = 2.5;\n\t\t\t\t\tcontext.stroke();\n\t\t\t\t\tfor (const crossing of profile.crossings || []) {\n\t\t\t\t\t\tconst x = xForDistance(crossing.distanceKm);\n\t\t\t\t\t\tconst label = `${crossing.name} ${Math.round(crossing.passElevationM)} m`;\n\t\t\t\t\t\tconst labelWidth = context.measureText(label).width;\n\t\t\t\t\t\tconst labelX = clamp(x, plot.left + labelWidth / 2 + 3, plot.right - labelWidth / 2 - 3);\n\t\t\t\t\t\tconst labelY = plot.top - 6;\n\t\t\t\t\t\tcontext.fillStyle = colors.plotSurfaceOverlay;\n\t\t\t\t\t\tcontext.fillRect(labelX - labelWidth / 2 - 3, labelY - 15, labelWidth + 6, 16);\n\t\t\t\t\t\tcontext.fillStyle = colors.crossingLabel;\n\t\t\t\t\t\tcontext.textAlign = \"center\";\n\t\t\t\t\t\tcontext.textBaseline = \"bottom\";\n\t\t\t\t\t\tcontext.fillText(label, labelX, labelY);\n\t\t\t\t\t}\n\n\t\t\t\t\tcontext.fillStyle = colors.subtle;\n\t\t\t\t\tcontext.textAlign = \"center\";\n\t\t\t\t\tcontext.textBaseline = \"top\";\n\t\t\t\t\tfor (let step = 0; step <= 4; step++) {\n\t\t\t\t\t\tconst distance = minDistance + step / 4 * (maxDistance - minDistance);\n\t\t\t\t\t\tcontext.fillText(formatDistance(distance), xForDistance(distance), plot.bottom + 10);\n\t\t\t\t\t}\n\n\t\t\t\t\tif (state.hoveredIndex >= 0) {\n\t\t\t\t\t\tconst point = points[state.hoveredIndex];\n\t\t\t\t\t\tconst x = xForDistance(point.distanceKm);\n\t\t\t\t\t\tconst y = yForElevation(point.elevationM);\n\t\t\t\t\t\tcontext.strokeStyle = colors.hoverLine;\n\t\t\t\t\t\tcontext.lineWidth = 1;\n\t\t\t\t\t\tcontext.setLineDash([3, 3]);\n\t\t\t\t\t\tcontext.beginPath();\n\t\t\t\t\t\tcontext.moveTo(x, plot.top);\n\t\t\t\t\t\tcontext.lineTo(x, plot.bottom);\n\t\t\t\t\t\tcontext.stroke();\n\t\t\t\t\t\tcontext.setLineDash([]);\n\t\t\t\t\t\tcontext.fillStyle = colors.forest;\n\t\t\t\t\t\tcontext.beginPath();\n\t\t\t\t\t\tcontext.arc(x, y, 5, 0, 2 * Math.PI);\n\t\t\t\t\t\tcontext.fill();\n\t\t\t\t\t}\n\t\t\t\t};\n\n\t\t\t\tconst clearHover = () => {\n\t\t\t\t\tstate.hoveredIndex = -1;\n\t\t\t\t\trouteCursor.setStyle({ opacity: 0, fillOpacity: 0 });\n\t\t\t\t\thoverOutput.textContent = \"Hover or focus the profile to inspect elevation.\";\n\t\t\t\t\tdraw();\n\t\t\t\t};\n\t\t\t\tconst showPoint = (index) => {\n\t\t\t\t\tstate.hoveredIndex = clamp(index, 0, points.length - 1);\n\t\t\t\t\tconst point = points[state.hoveredIndex];\n\t\t\t\t\trouteCursor.setLatLng([point.latitude, point.longitude]);\n\t\t\t\t\trouteCursor.setStyle({ opacity: 1, fillOpacity: 0.9 });\n\t\t\t\t\thoverOutput.textContent = `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`;\n\t\t\t\t\tdraw();\n\t\t\t\t};\n\t\t\t\tcanvas.addEventListener(\"pointermove\", (event) => {\n\t\t\t\t\tif (!state.plot) return;\n\t\t\t\t\tconst rect = canvas.getBoundingClientRect();\n\t\t\t\t\tconst x = event.clientX - rect.left;\n\t\t\t\t\tif (x < state.plot.left || x > state.plot.right) {\n\t\t\t\t\t\tclearHover();\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tconst distance = minDistance + (x - state.plot.left) / (state.plot.right - state.plot.left) * (maxDistance - minDistance);\n\t\t\t\t\tshowPoint(nearestPointIndex(distance));\n\t\t\t\t});\n\t\t\t\tcanvas.addEventListener(\"pointerleave\", clearHover);\n\t\t\t\tcanvas.addEventListener(\"pointercancel\", clearHover);\n\t\t\t\tcanvas.addEventListener(\"keydown\", (event) => {\n\t\t\t\t\tif (event.key !== \"ArrowLeft\" && event.key !== \"ArrowRight\") return;\n\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\tconst direction = event.key === \"ArrowRight\" ? 1 : -1;\n\t\t\t\t\tconst index = state.hoveredIndex < 0 ? (direction > 0 ? 0 : points.length - 1) : state.hoveredIndex + direction;\n\t\t\t\t\tshowPoint(index);\n\t\t\t\t});\n\t\t\t\twindow.addEventListener(\"resize\", draw);\n\t\t\t\tdraw();\n\t\t\t})();\n\t\t</script>")
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" <script src=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\" defer></script> <script src=\"/static/ride-detail.js\" defer></script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -569,7 +569,7 @@ func SyncContent(data SyncPageData) templ.Component {
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 351, Col: 52}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 105, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
@@ -593,7 +593,7 @@ func SyncContent(data SyncPageData) templ.Component {
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 356, Col: 49}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 110, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
@@ -635,7 +635,7 @@ func SyncContent(data SyncPageData) templ.Component {
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(data.From)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 367, Col: 64}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 121, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
@@ -648,7 +648,7 @@ func SyncContent(data SyncPageData) templ.Component {
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(data.To)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 368, Col: 58}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 122, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {