Skip to content

Commit e8d3620

Browse files
CopilotOlliV
andauthored
Add Veloviewer-style explorer tiles to /history page
- lib/explorer_tiles.ts: lat/lon→tile conversion, visited-tile collection, max-square DP algorithm - components/map/ExplorerTilesLayer.tsx: Leaflet layer (blue fill for visited tiles, orange border for max square) - pages/history.tsx: Explorer Tiles section with stats (tile count, max square size) and interactive map Agent-Logs-Url: https://github.com/bfree-trainer/bfree/sessions/530d55d1-269a-4442-94d2-0a1226f3ba16 Co-authored-by: OlliV <1401625+OlliV@users.noreply.github.com>
1 parent 1d58c90 commit e8d3620

3 files changed

Lines changed: 322 additions & 1 deletion

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// SPDX-FileCopyrightText: Olli Vanhoja <olli.vanhoja@gmail.com>
2+
//
3+
// SPDX-License-Identifier: GPL-3.0-or-later
4+
5+
'use client';
6+
import { useEffect } from 'react';
7+
import L from 'leaflet';
8+
import { useMap } from 'react-leaflet';
9+
import { EXPLORER_ZOOM, collectVisitedTiles, findMaxSquare, tileToBounds } from 'lib/explorer_tiles';
10+
11+
/**
12+
* Renders Veloviewer-style explorer tiles on a Leaflet map:
13+
*
14+
* - Each visited OSM zoom-14 tile is drawn as a semi-transparent blue square.
15+
* - The largest contiguous square of visited tiles (the "max square") is
16+
* highlighted with an orange border, matching the Veloviewer style.
17+
*
18+
* Uses the native Leaflet API via `useEffect` for performance — adding
19+
* individual React elements for potentially thousands of tiles would be slow.
20+
*/
21+
export default function ExplorerTilesLayer({ tracks }: { tracks: [number, number][][] }) {
22+
const map = useMap();
23+
24+
useEffect(() => {
25+
if (!map) return;
26+
27+
const visitedTiles = collectVisitedTiles(tracks, EXPLORER_ZOOM);
28+
if (visitedTiles.size === 0) return;
29+
30+
const maxSquare = findMaxSquare(visitedTiles);
31+
const layerGroup = L.layerGroup().addTo(map);
32+
33+
// Draw all visited tiles as semi-transparent blue rectangles.
34+
for (const key of visitedTiles) {
35+
const parts = key.split(',');
36+
const tx = Number(parts[0]);
37+
const ty = Number(parts[1]);
38+
const b = tileToBounds(tx, ty, EXPLORER_ZOOM);
39+
L.rectangle(
40+
[
41+
[b.south, b.west],
42+
[b.north, b.east],
43+
],
44+
{
45+
color: '#1976D2',
46+
fillColor: '#1976D2',
47+
fillOpacity: 0.3,
48+
weight: 0.5,
49+
opacity: 0.6,
50+
},
51+
).addTo(layerGroup);
52+
}
53+
54+
// Draw the max-square outline in Veloviewer orange.
55+
if (maxSquare) {
56+
const nwBounds = tileToBounds(maxSquare.minX, maxSquare.minY, EXPLORER_ZOOM);
57+
const seBounds = tileToBounds(
58+
maxSquare.minX + maxSquare.size - 1,
59+
maxSquare.minY + maxSquare.size - 1,
60+
EXPLORER_ZOOM,
61+
);
62+
L.rectangle(
63+
[
64+
[seBounds.south, nwBounds.west],
65+
[nwBounds.north, seBounds.east],
66+
],
67+
{
68+
color: '#ff7700',
69+
fill: false,
70+
weight: 3,
71+
opacity: 0.9,
72+
},
73+
).addTo(layerGroup);
74+
}
75+
76+
// Fit the map to show all visited tiles with a small padding.
77+
const tileCoords = Array.from(visitedTiles).map((s) => {
78+
const parts = s.split(',');
79+
return [Number(parts[0]), Number(parts[1])] as [number, number];
80+
});
81+
const minX = Math.min(...tileCoords.map(([x]) => x));
82+
const maxX = Math.max(...tileCoords.map(([x]) => x));
83+
const minY = Math.min(...tileCoords.map(([, y]) => y));
84+
const maxY = Math.max(...tileCoords.map(([, y]) => y));
85+
const swFit = tileToBounds(minX, maxY, EXPLORER_ZOOM);
86+
const neFit = tileToBounds(maxX, minY, EXPLORER_ZOOM);
87+
map.fitBounds(
88+
[
89+
[swFit.south, swFit.west],
90+
[neFit.north, neFit.east],
91+
],
92+
{ padding: [24, 24] },
93+
);
94+
95+
return () => {
96+
layerGroup.remove();
97+
};
98+
}, [map, tracks]);
99+
100+
return null;
101+
}
102+
103+
export type ExplorerTilesLayerArgs = Parameters<typeof ExplorerTilesLayer>[0];

lib/explorer_tiles.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// SPDX-FileCopyrightText: Olli Vanhoja <olli.vanhoja@gmail.com>
2+
//
3+
// SPDX-License-Identifier: GPL-3.0-or-later
4+
5+
/**
6+
* Veloviewer-style explorer tiles.
7+
*
8+
* Tiles are based on the standard OpenStreetMap tile grid at zoom level 14
9+
* (256×256 px tiles). Any activity trackpoint that falls inside a tile marks
10+
* that tile as "visited". The max square is the largest N×N block of
11+
* contiguous visited tiles.
12+
*
13+
* References:
14+
* - https://wiki.openstreetmap.org/wiki/Zoom_levels
15+
* - https://blog.veloviewer.com/veloviewer-explorer-score-and-max-square/
16+
*/
17+
18+
/** OSM zoom level used for explorer tiles (matches Veloviewer / Statshunter). */
19+
export const EXPLORER_ZOOM = 14;
20+
21+
/**
22+
* Convert geographic coordinates to OSM tile coordinates at the given zoom.
23+
* Returns integer tile indices [tileX, tileY].
24+
*/
25+
export function latLonToTile(lat: number, lon: number, zoom: number = EXPLORER_ZOOM): [number, number] {
26+
const n = Math.pow(2, zoom);
27+
const tileX = Math.floor(((lon + 180) / 360) * n);
28+
const latRad = (lat * Math.PI) / 180;
29+
const tileY = Math.floor(((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n);
30+
return [tileX, tileY];
31+
}
32+
33+
/** Geographic bounding box for a tile. */
34+
export interface TileBounds {
35+
north: number;
36+
south: number;
37+
west: number;
38+
east: number;
39+
}
40+
41+
/**
42+
* Convert tile coordinates back to geographic bounds.
43+
* In OSM tiling, tile Y increases southward, so tile (tx, ty) has its
44+
* north edge at ty and south edge at ty+1.
45+
*/
46+
export function tileToBounds(tx: number, ty: number, zoom: number = EXPLORER_ZOOM): TileBounds {
47+
const n = Math.pow(2, zoom);
48+
const west = (tx / n) * 360 - 180;
49+
const east = ((tx + 1) / n) * 360 - 180;
50+
const northRad = Math.atan(Math.sinh(Math.PI * (1 - (2 * ty) / n)));
51+
const southRad = Math.atan(Math.sinh(Math.PI * (1 - (2 * (ty + 1)) / n)));
52+
return {
53+
north: (northRad * 180) / Math.PI,
54+
south: (southRad * 180) / Math.PI,
55+
west,
56+
east,
57+
};
58+
}
59+
60+
/**
61+
* Collect all unique OSM tiles visited by the given tracks.
62+
* Each tile is represented as a "tileX,tileY" string key.
63+
*/
64+
export function collectVisitedTiles(tracks: [number, number][][], zoom: number = EXPLORER_ZOOM): Set<string> {
65+
const tiles = new Set<string>();
66+
for (const track of tracks) {
67+
for (const [lat, lon] of track) {
68+
const [tx, ty] = latLonToTile(lat, lon, zoom);
69+
tiles.add(`${tx},${ty}`);
70+
}
71+
}
72+
return tiles;
73+
}
74+
75+
/** Position and size of the max square in tile coordinates. */
76+
export interface MaxSquare {
77+
/** X (column) of the top-left tile of the square. */
78+
minX: number;
79+
/** Y (row) of the top-left tile of the square. */
80+
minY: number;
81+
/** Side length of the square in tiles. */
82+
size: number;
83+
}
84+
85+
/**
86+
* Find the largest square of fully-visited contiguous tiles using a classic
87+
* dynamic-programming approach (O(rows × cols) time).
88+
*
89+
* Returns null when there are no visited tiles.
90+
*/
91+
export function findMaxSquare(tiles: Set<string>): MaxSquare | null {
92+
if (tiles.size === 0) return null;
93+
94+
const coords = Array.from(tiles).map((s) => {
95+
const [x, y] = s.split(',').map(Number);
96+
return [x, y] as [number, number];
97+
});
98+
99+
const minX = Math.min(...coords.map(([x]) => x));
100+
const maxX = Math.max(...coords.map(([x]) => x));
101+
const minY = Math.min(...coords.map(([, y]) => y));
102+
const maxY = Math.max(...coords.map(([, y]) => y));
103+
104+
const width = maxX - minX + 1;
105+
const height = maxY - minY + 1;
106+
107+
// dp[row][col] = side length of the largest all-visited square whose
108+
// bottom-right corner (in row-major order, y increasing downward) is
109+
// at this cell.
110+
const dp: number[][] = Array.from({ length: height }, () => new Array(width).fill(0));
111+
112+
let bestSize = 0;
113+
// Bottom-right tile coordinates of the best square found so far.
114+
let bestTileX = minX;
115+
let bestTileY = minY;
116+
117+
for (let row = 0; row < height; row++) {
118+
for (let col = 0; col < width; col++) {
119+
const tx = minX + col;
120+
const ty = minY + row;
121+
if (!tiles.has(`${tx},${ty}`)) continue;
122+
123+
if (row === 0 || col === 0) {
124+
dp[row][col] = 1;
125+
} else {
126+
dp[row][col] = Math.min(dp[row - 1][col], dp[row][col - 1], dp[row - 1][col - 1]) + 1;
127+
}
128+
129+
if (dp[row][col] > bestSize) {
130+
bestSize = dp[row][col];
131+
bestTileX = tx;
132+
bestTileY = ty;
133+
}
134+
}
135+
}
136+
137+
if (bestSize === 0) return null;
138+
139+
// The top-left tile of the best square:
140+
return {
141+
minX: bestTileX - bestSize + 1,
142+
minY: bestTileY - bestSize + 1,
143+
size: bestSize,
144+
};
145+
}

pages/history.tsx

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import CardHeader from '@mui/material/CardHeader';
1515
import Checkbox from '@mui/material/Checkbox';
1616
import Collapse from '@mui/material/Collapse';
1717
import Container from '@mui/material/Container';
18+
import Divider from '@mui/material/Divider';
1819
import Grid from '@mui/material/Grid';
1920
import IconButton, { IconButtonProps } from '@mui/material/IconButton';
2021
import IconDelete from '@mui/icons-material/Delete';
@@ -23,12 +24,13 @@ import IconExpandMore from '@mui/icons-material/ExpandMore';
2324
import IconMoreVert from '@mui/icons-material/MoreVert';
2425
import Menu from '@mui/material/Menu';
2526
import MenuItem from '@mui/material/MenuItem';
27+
import Paper from '@mui/material/Paper';
2628
import Snackbar from '@mui/material/Snackbar';
2729
import Typography from '@mui/material/Typography';
2830
import useMediaQuery from '@mui/material/useMediaQuery';
2931
import { useTheme, styled } from '@mui/material/styles';
3032
import { red } from '@mui/material/colors';
31-
import { useState, useEffect, useRef, ChangeEvent } from 'react';
33+
import { useState, useEffect, useRef, useMemo, ChangeEvent } from 'react';
3234
import BottomNavi from 'components/BottomNavi';
3335
import MyHead from 'components/MyHead';
3436
import Title from 'components/Title';
@@ -40,13 +42,21 @@ import { gpxDocument2obj, parseGpxFile2Document } from 'lib/gpx_parser';
4042
import { getElapsedTimeStr } from 'lib/format';
4143
import { smartDistanceUnitFormat } from 'lib/units';
4244
import { useGlobalState } from 'lib/global';
45+
import { collectVisitedTiles, findMaxSquare } from 'lib/explorer_tiles';
4346
import type RideMiniMapType from 'components/map/RideMiniMap';
47+
import type { OpenStreetMapArg } from 'components/map/OpenStreetMap';
48+
import type { ExplorerTilesLayerArgs } from 'components/map/ExplorerTilesLayer';
4449

4550
type RideMiniMapArgs = Parameters<typeof RideMiniMapType>[0];
4651
const DynamicRideMiniMap = dynamic<RideMiniMapArgs>(() => import('components/map/RideMiniMap'), {
4752
ssr: false,
4853
});
4954
const DataGraph = dynamic(() => import('components/DataGraph'), { ssr: false });
55+
const DynamicMap = dynamic<OpenStreetMapArg>(() => import('components/map/OpenStreetMap'), { ssr: false });
56+
const DynamicExplorerTilesLayer = dynamic<ExplorerTilesLayerArgs>(
57+
() => import('components/map/ExplorerTilesLayer'),
58+
{ ssr: false },
59+
);
5060

5161
const VisuallyHiddenInput = styled('input')({
5262
clip: 'rect(0 0 0 0)',
@@ -366,6 +376,28 @@ export default function History() {
366376
});
367377
};
368378

379+
// Extract GPS tracks from all logs for explorer tiles computation.
380+
const tracks = useMemo<[number, number][][]>(() => {
381+
return logs
382+
.map((log) =>
383+
log.logger
384+
.getLaps()
385+
.flatMap((lap) => lap.trackPoints)
386+
.filter(
387+
(tp) =>
388+
tp.position && typeof tp.position.lat === 'number' && typeof tp.position.lon === 'number',
389+
)
390+
.map((tp) => [tp.position.lat, tp.position.lon] as [number, number]),
391+
)
392+
.filter((positions) => positions.length > 0);
393+
}, [logs]);
394+
395+
const explorerTiles = useMemo(() => collectVisitedTiles(tracks), [tracks]);
396+
const maxSquare = useMemo(() => findMaxSquare(explorerTiles), [explorerTiles]);
397+
398+
const explorerCenter: [number, number] =
399+
tracks.length > 0 && tracks[0].length > 0 ? tracks[0][0] : [51.505, -0.09];
400+
369401
useEffect(() => {
370402
setLogs(getActivityLogs());
371403
}, []);
@@ -426,6 +458,47 @@ export default function History() {
426458
<RideStatsPanel logs={logs} />
427459
</Grid>
428460
</Grid>
461+
462+
{/* Explorer Tiles section — shown only when there are GPS tracks */}
463+
{explorerTiles.size > 0 && (
464+
<Box sx={{ mt: 4 }}>
465+
<Divider sx={{ mb: 3 }} />
466+
<Typography variant="h6" fontWeight={700} gutterBottom>
467+
Explorer Tiles
468+
</Typography>
469+
<Paper variant="outlined" sx={{ p: 2, mb: 2, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
470+
<Box>
471+
<Typography variant="h4" fontWeight={700} color="primary">
472+
{explorerTiles.size}
473+
</Typography>
474+
<Typography variant="caption" color="text.secondary">
475+
tiles visited
476+
</Typography>
477+
</Box>
478+
{maxSquare && (
479+
<Box>
480+
<Typography variant="h4" fontWeight={700} sx={{ color: '#ff7700' }}>
481+
{maxSquare.size}×{maxSquare.size}
482+
</Typography>
483+
<Typography variant="caption" color="text.secondary">
484+
max square
485+
</Typography>
486+
</Box>
487+
)}
488+
</Paper>
489+
<Box sx={{ borderRadius: 2, overflow: 'hidden', boxShadow: 1 }}>
490+
<DynamicMap
491+
center={explorerCenter}
492+
width="100%"
493+
height="clamp(300px, 55vh, 600px)"
494+
setMap={null}
495+
ariaLabel="Map showing visited explorer tiles and max square"
496+
>
497+
<DynamicExplorerTilesLayer tracks={tracks} />
498+
</DynamicMap>
499+
</Box>
500+
</Box>
501+
)}
429502
</Box>
430503
<Snackbar open={!!snackMsg} autoHideDuration={4000} onClose={() => setSnackMsg(null)} message={snackMsg} />
431504
<BottomNavi>

0 commit comments

Comments
 (0)