6 Commits

Author SHA1 Message Date
vcoppe
c9ca75e2e8 small ui improvements 2026-02-17 22:24:14 +01:00
vcoppe
091f6a3ed0 adapt routing control size to canvas width 2026-02-17 21:12:04 +01:00
vcoppe
d6c9fb1025 split routing controls in zoom-specific layers to improve performance 2026-02-14 15:05:23 +01:00
vcoppe
88abd72a41 layer instead of markers for routing controls 2026-02-14 14:35:35 +01:00
vcoppe
1137e851ce remove company support section until clarified 2026-02-11 18:31:08 +01:00
vcoppe
b8c1500aad fix layer filtering in event manager 2026-02-02 21:50:01 +01:00
17 changed files with 915 additions and 631 deletions

View File

@@ -31,13 +31,13 @@
<Card.Root <Card.Root
class="h-full {orientation === 'vertical' class="h-full {orientation === 'vertical'
? 'min-w-40 sm:min-w-44 text-sm sm:text-base' ? 'min-w-40 sm:min-w-44'
: 'w-full'} border-none shadow-none p-0" : 'w-full h-10'} border-none shadow-none p-0 text-sm sm:text-base"
> >
<Card.Content <Card.Content
class="h-full flex {orientation === 'vertical' class="h-full flex {orientation === 'vertical'
? 'flex-col justify-center' ? 'flex-col justify-center'
: 'flex-row w-full justify-between'} gap-4 p-0" : 'flex-row w-full justify-evenly'} gap-4 p-0"
> >
<Tooltip label={i18n._('quantities.distance')}> <Tooltip label={i18n._('quantities.distance')}>
<span class="flex flex-row items-center"> <span class="flex flex-row items-center">

View File

@@ -64,7 +64,7 @@
}); });
</script> </script>
<div class="h-full grow min-w-0 relative py-2"> <div class="h-full grow min-w-0 min-h-0 relative">
<canvas bind:this={overlay} class="w-full h-full absolute pointer-events-none"></canvas> <canvas bind:this={overlay} class="w-full h-full absolute pointer-events-none"></canvas>
<canvas bind:this={canvas} class="w-full h-full absolute"></canvas> <canvas bind:this={canvas} class="w-full h-full absolute"></canvas>
{#if showControls} {#if showControls}

View File

@@ -5,6 +5,16 @@
map.onLoad((map_) => { map.onLoad((map_) => {
map_.on('contextmenu', (e) => { map_.on('contextmenu', (e) => {
if (
map_.queryRenderedFeatures(e.point, {
layers: map_
.getLayersOrder()
.filter((layerId) => layerId.startsWith('routing-controls')),
}).length
) {
// Clicked on routing control, ignoring
return;
}
trackpointPopup?.setItem({ trackpointPopup?.setItem({
item: new TrackPoint({ item: new TrackPoint({
attributes: { attributes: {

View File

@@ -129,12 +129,21 @@
@apply relative; @apply relative;
@apply top-0; @apply top-0;
@apply left-0; @apply left-0;
@apply my-2;
@apply w-[29px]; @apply w-[29px];
} }
div :global(.maplibregl-ctrl-geocoder--icon-loading) {
@apply -mt-1;
@apply mb-0;
}
div :global(.maplibregl-ctrl-geocoder--icon-close) {
@apply my-0;
}
div :global(.maplibregl-ctrl-geocoder--input) { div :global(.maplibregl-ctrl-geocoder--input) {
@apply relative; @apply relative;
@apply h-8;
@apply w-64; @apply w-64;
@apply py-0; @apply py-0;
@apply pl-2; @apply pl-2;

View File

@@ -287,6 +287,7 @@ export class GPXLayer {
_map.addSource(this.fileId + '-waypoints', { _map.addSource(this.fileId + '-waypoints', {
type: 'geojson', type: 'geojson',
data: this.currentWaypointData, data: this.currentWaypointData,
promoteId: 'waypointIndex',
}); });
} }
@@ -645,7 +646,17 @@ export class GPXLayer {
| GeoJSONSource | GeoJSONSource
| undefined; | undefined;
if (waypointSource) { if (waypointSource) {
waypointSource.setData(this.currentWaypointData!); waypointSource.updateData({
update: [
{
id: this.draggedWaypointIndex,
newGeometry: {
type: 'Point',
coordinates: [e.lngLat.lng, e.lngLat.lat],
},
},
],
});
} }
} }

View File

@@ -50,17 +50,20 @@ export class StartEndMarkers {
const slicedStatistics = get(slicedGPXStatistics); const slicedStatistics = get(slicedGPXStatistics);
const hovered = get(hoveredPoint); const hovered = get(hoveredPoint);
const hidden = get(allHidden); const hidden = get(allHidden);
if (statistics.global.length > 0 && tool !== Tool.ROUTING && !hidden) { if (!hidden) {
const data: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [],
};
if (statistics.global.length > 0 && tool !== Tool.ROUTING) {
const start = statistics const start = statistics
.getTrackPoint(slicedStatistics?.[1] ?? 0)! .getTrackPoint(slicedStatistics?.[1] ?? 0)!
.trkpt.getCoordinates(); .trkpt.getCoordinates();
const end = statistics const end = statistics
.getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)! .getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)!
.trkpt.getCoordinates(); .trkpt.getCoordinates();
const data: GeoJSON.FeatureCollection = { data.features.push({
type: 'FeatureCollection',
features: [
{
type: 'Feature', type: 'Feature',
geometry: { geometry: {
type: 'Point', type: 'Point',
@@ -69,8 +72,8 @@ export class StartEndMarkers {
properties: { properties: {
icon: 'start-marker', icon: 'start-marker',
}, },
}, });
{ data.features.push({
type: 'Feature', type: 'Feature',
geometry: { geometry: {
type: 'Point', type: 'Point',
@@ -79,9 +82,8 @@ export class StartEndMarkers {
properties: { properties: {
icon: 'end-marker', icon: 'end-marker',
}, },
}, });
], }
};
if (hovered) { if (hovered) {
data.features.push({ data.features.push({

View File

@@ -142,21 +142,7 @@ export class MapLayerEventManager {
} }
private _handleMouseMove(e: maplibregl.MapMouseEvent) { private _handleMouseMove(e: maplibregl.MapMouseEvent) {
const layerIds = this._filterLayersContainingCoordinate( const featuresByLayer = this._getRenderedFeaturesByLayer(e);
Object.keys(this._listeners),
e.lngLat
);
const features =
layerIds.length > 0
? this._map.queryRenderedFeatures(e.point, { layers: layerIds })
: [];
const featuresByLayer: Record<string, maplibregl.MapGeoJSONFeature[]> = {};
features.forEach((f) => {
if (!featuresByLayer[f.layer.id]) {
featuresByLayer[f.layer.id] = [];
}
featuresByLayer[f.layer.id].push(f);
});
Object.keys(this._listeners).forEach((layerId) => { Object.keys(this._listeners).forEach((layerId) => {
const features = featuresByLayer[layerId] || []; const features = featuresByLayer[layerId] || [];
const listener = this._listeners[layerId]; const listener = this._listeners[layerId];
@@ -183,7 +169,6 @@ export class MapLayerEventManager {
listener.mouseleaves.forEach((l) => l(event)); listener.mouseleaves.forEach((l) => l(event));
} }
} }
listener.features = features;
} }
if (features.length > 0 && listener.mousemoves.length > 0) { if (features.length > 0 && listener.mousemoves.length > 0) {
const event = new maplibregl.MapMouseEvent('mousemove', e.target, e.originalEvent, { const event = new maplibregl.MapMouseEvent('mousemove', e.target, e.originalEvent, {
@@ -191,15 +176,19 @@ export class MapLayerEventManager {
}); });
listener.mousemoves.forEach((l) => l(event)); listener.mousemoves.forEach((l) => l(event));
} }
listener.features = features;
}); });
} }
private _handleMouseClick(type: string, e: maplibregl.MapMouseEvent) { private _handleMouseClick(type: string, e: maplibregl.MapMouseEvent) {
Object.values(this._listeners).forEach((listener) => { const featuresByLayer = this._getRenderedFeaturesByLayer(e);
if (listener.features.length > 0) { Object.keys(this._listeners).forEach((layerId) => {
const features = featuresByLayer[layerId] || [];
const listener = this._listeners[layerId];
if (features.length > 0) {
if (type === 'click' && listener.clicks.length > 0) { if (type === 'click' && listener.clicks.length > 0) {
const event = new maplibregl.MapMouseEvent('click', e.target, e.originalEvent, { const event = new maplibregl.MapMouseEvent('click', e.target, e.originalEvent, {
features: listener.features, features: features,
}); });
listener.clicks.forEach((l) => l(event)); listener.clicks.forEach((l) => l(event));
} else if (type === 'contextmenu' && listener.contextmenus.length > 0) { } else if (type === 'contextmenu' && listener.contextmenus.length > 0) {
@@ -208,7 +197,7 @@ export class MapLayerEventManager {
e.target, e.target,
e.originalEvent, e.originalEvent,
{ {
features: listener.features, features: features,
} }
); );
listener.contextmenus.forEach((l) => l(event)); listener.contextmenus.forEach((l) => l(event));
@@ -218,7 +207,7 @@ export class MapLayerEventManager {
e.target, e.target,
e.originalEvent, e.originalEvent,
{ {
features: listener.features, features: features,
} }
); );
listener.mousedowns.forEach((l) => l(event)); listener.mousedowns.forEach((l) => l(event));
@@ -228,21 +217,7 @@ export class MapLayerEventManager {
} }
private _handleTouchStart(e: maplibregl.MapTouchEvent) { private _handleTouchStart(e: maplibregl.MapTouchEvent) {
const layerIds = this._filterLayersContainingCoordinate( const featuresByLayer = this._getRenderedFeaturesByLayer(e);
Object.keys(this._listeners).filter(
(layerId) => this._listeners[layerId].touchstarts.length > 0
),
e.lngLat
);
if (layerIds.length === 0) return;
const features = this._map.queryRenderedFeatures(e.points[0], { layers: layerIds });
const featuresByLayer: Record<string, maplibregl.MapGeoJSONFeature[]> = {};
features.forEach((f) => {
if (!featuresByLayer[f.layer.id]) {
featuresByLayer[f.layer.id] = [];
}
featuresByLayer[f.layer.id].push(f);
});
Object.keys(this._listeners).forEach((layerId) => { Object.keys(this._listeners).forEach((layerId) => {
const features = featuresByLayer[layerId] || []; const features = featuresByLayer[layerId] || [];
const listener = this._listeners[layerId]; const listener = this._listeners[layerId];
@@ -258,19 +233,49 @@ export class MapLayerEventManager {
}); });
} }
private _filterLayersContainingCoordinate( private _getBounds(point: maplibregl.Point) {
const delta = 30;
return new maplibregl.LngLatBounds(
this._map.unproject([point.x - delta, point.y + delta]),
this._map.unproject([point.x + delta, point.y - delta])
);
}
private _filterLayersIntersectingBounds(
layerIds: string[], layerIds: string[],
lngLat: maplibregl.LngLat bounds: maplibregl.LngLatBounds
): string[] { ): string[] {
let result = layerIds.filter((layerId) => { let result = layerIds.filter((layerId) => {
if (!this._map.getLayer(layerId)) return false; if (!this._map.getLayer(layerId)) return false;
const fileId = layerId.replace('-waypoints', ''); const fileId = layerId.replace('-waypoints', '');
if (fileId === layerId) { if (fileId === layerId) {
return fileStateCollection.getStatistics(fileId)?.inBBox(lngLat) ?? true; return fileStateCollection.getStatistics(fileId)?.intersectsBBox(bounds) ?? true;
} else { } else {
return fileStateCollection.getStatistics(fileId)?.inWaypointBBox(lngLat) ?? true; return (
fileStateCollection.getStatistics(fileId)?.intersectsWaypointBBox(bounds) ??
true
);
} }
}); });
return result; return result;
} }
private _getRenderedFeaturesByLayer(e: maplibregl.MapMouseEvent | maplibregl.MapTouchEvent) {
const layerIds = this._filterLayersIntersectingBounds(
Object.keys(this._listeners),
this._getBounds(e.point)
);
const features =
layerIds.length > 0
? this._map.queryRenderedFeatures(e.point, { layers: layerIds })
: [];
const featuresByLayer: Record<string, maplibregl.MapGeoJSONFeature[]> = {};
features.forEach((f) => {
if (!featuresByLayer[f.layer.id]) {
featuresByLayer[f.layer.id] = [];
}
featuresByLayer[f.layer.id].push(f);
});
return featuresByLayer;
}
} }

View File

@@ -29,6 +29,7 @@ export const ANCHOR_LAYER_KEY = {
interactions: 'interactions-end', interactions: 'interactions-end',
overpass: 'overpass-end', overpass: 'overpass-end',
waypoints: 'waypoints-end', waypoints: 'waypoints-end',
routingControls: 'routing-controls-end',
}; };
const anchorLayers: maplibregl.LayerSpecification[] = Object.values(ANCHOR_LAYER_KEY).map((id) => ({ const anchorLayers: maplibregl.LayerSpecification[] = Object.values(ANCHOR_LAYER_KEY).map((id) => ({
id: id, id: id,

View File

@@ -2,15 +2,21 @@ import { ramerDouglasPeucker, type GPXFile, type TrackSegment } from 'gpx';
const earthRadius = 6371008.8; const earthRadius = 6371008.8;
export const MIN_ANCHOR_ZOOM = 0;
export const MAX_ANCHOR_ZOOM = 22;
export function getZoomLevelForDistance(latitude: number, distance?: number): number { export function getZoomLevelForDistance(latitude: number, distance?: number): number {
if (distance === undefined) { if (distance === undefined) {
return 0; return MIN_ANCHOR_ZOOM;
} }
const rad = Math.PI / 180; const rad = Math.PI / 180;
const lat = latitude * rad; const lat = latitude * rad;
return Math.min(22, Math.max(0, Math.log2((earthRadius * Math.cos(lat)) / distance))); return Math.min(
MAX_ANCHOR_ZOOM,
Math.max(MIN_ANCHOR_ZOOM, Math.round(Math.log2((earthRadius * Math.cos(lat)) / distance)))
);
} }
export function updateAnchorPoints(file: GPXFile) { export function updateAnchorPoints(file: GPXFile) {

View File

@@ -1,2 +0,0 @@
MapTiler is the company that provides some of the beautiful maps on this website.
This partnership allows **gpx.studio** to benefit from MapTiler tools at discounted prices, greatly contributing to the financial viability of the project and enabling us to offer the best possible user experience.

View File

@@ -7,7 +7,8 @@ export enum MapCursorState {
TOOL_WITH_CROSSHAIR, TOOL_WITH_CROSSHAIR,
WAYPOINT_HOVER, WAYPOINT_HOVER,
WAYPOINT_DRAGGING, WAYPOINT_DRAGGING,
TRACKPOINT_DRAGGING, ANCHOR_HOVER,
ANCHOR_DRAGGING,
SCISSORS, SCISSORS,
SPLIT_CONTROL, SPLIT_CONTROL,
MAPILLARY_HOVER, MAPILLARY_HOVER,
@@ -20,7 +21,8 @@ const cursorStyles = {
[MapCursorState.LAYER_HOVER]: 'pointer', [MapCursorState.LAYER_HOVER]: 'pointer',
[MapCursorState.WAYPOINT_HOVER]: 'pointer', [MapCursorState.WAYPOINT_HOVER]: 'pointer',
[MapCursorState.WAYPOINT_DRAGGING]: 'grabbing', [MapCursorState.WAYPOINT_DRAGGING]: 'grabbing',
[MapCursorState.TRACKPOINT_DRAGGING]: 'grabbing', [MapCursorState.ANCHOR_HOVER]: 'pointer',
[MapCursorState.ANCHOR_DRAGGING]: 'grabbing',
[MapCursorState.TOOL_WITH_CROSSHAIR]: 'crosshair', [MapCursorState.TOOL_WITH_CROSSHAIR]: 'crosshair',
[MapCursorState.SCISSORS]: scissorsCursor, [MapCursorState.SCISSORS]: scissorsCursor,
[MapCursorState.SPLIT_CONTROL]: 'pointer', [MapCursorState.SPLIT_CONTROL]: 'pointer',

View File

@@ -49,7 +49,7 @@ export class GPXStatisticsTree {
return statistics; return statistics;
} }
inBBox(coordinates: { lat: number; lng: number }): boolean { intersectsBBox(bounds: maplibregl.LngLatBounds): boolean {
for (let key in this.statistics) { for (let key in this.statistics) {
const stats = this.statistics[key]; const stats = this.statistics[key];
if (stats instanceof GPXStatistics) { if (stats instanceof GPXStatistics) {
@@ -57,18 +57,18 @@ export class GPXStatisticsTree {
stats.global.bounds.southWest, stats.global.bounds.southWest,
stats.global.bounds.northEast stats.global.bounds.northEast
); );
if (!bbox.isEmpty() && bbox.contains(coordinates)) { if (!bbox.isEmpty() && bbox.intersects(bounds)) {
return true; return true;
} }
} else if (stats.inBBox(coordinates)) { } else if (stats.intersectsBBox(bounds)) {
return true; return true;
} }
} }
return false; return false;
} }
inWaypointBBox(coordinates: { lat: number; lng: number }): boolean { intersectsWaypointBBox(bounds: maplibregl.LngLatBounds): boolean {
return !this.wptBounds.isEmpty() && this.wptBounds.contains(coordinates); return !this.wptBounds.isEmpty() && this.wptBounds.intersects(bounds);
} }
} }
export type GPXFileWithStatistics = { file: GPXFile; statistics: GPXStatisticsTree }; export type GPXFileWithStatistics = { file: GPXFile; statistics: GPXStatisticsTree };

View File

@@ -197,9 +197,9 @@ export function getElevation(
); );
} }
export function loadSVGIcon(map: maplibregl.Map, id: string, svg: string) { export function loadSVGIcon(map: maplibregl.Map, id: string, svg: string, size: number = 100) {
if (!map.hasImage(id)) { if (!map.hasImage(id)) {
let icon = new Image(100, 100); let icon = new Image(size, size);
icon.onload = () => { icon.onload = () => {
if (!map.hasImage(id)) { if (!map.hasImage(id)) {
map.addImage(id, icon); map.addImage(id, icon);

View File

@@ -29,7 +29,6 @@
data: { data: {
fundingModule: Promise<any>; fundingModule: Promise<any>;
translationModule: Promise<any>; translationModule: Promise<any>;
maptilerModule: Promise<any>;
}; };
} = $props(); } = $props();
@@ -272,23 +271,4 @@
</Button> </Button>
</div> </div>
</div> </div>
<div class="px-12 md:px-24 flex flex-col items-center">
<div
class="max-w-4xl flex flex-col lg:flex-row items-center justify-center gap-x-12 gap-y-6 p-6 border rounded-2xl shadow-xl bg-secondary"
>
<div
class="shrink-0 flex flex-col sm:flex-row lg:flex-col items-center gap-x-4 gap-y-2"
>
<div class="text-lg font-semibold text-muted-foreground">
❤️ {i18n._('homepage.supported_by')}
</div>
<a href="https://www.maptiler.com/" target="_blank">
<Logo company="maptiler" class="w-60" />
</a>
</div>
{#await data.maptilerModule then maptilerModule}
<DocsContainer module={maptilerModule.default} />
{/await}
</div>
</div>
</div> </div>

View File

@@ -9,6 +9,5 @@ export async function load({ params }) {
return { return {
fundingModule: getModule(language, 'funding'), fundingModule: getModule(language, 'funding'),
translationModule: getModule(language, 'translation'), translationModule: getModule(language, 'translation'),
maptilerModule: getModule(language, 'maptiler'),
}; };
} }

View File

@@ -30,6 +30,11 @@
elevationFill, elevationFill,
} = settings; } = settings;
let bottomPanelWidth: number | undefined = $state();
let bottomPanelOrientation = $derived(
bottomPanelWidth && bottomPanelWidth >= 540 && $elevationProfile ? 'horizontal' : 'vertical'
);
onMount(async () => { onMount(async () => {
settings.connectToDatabase(db); settings.connectToDatabase(db);
fileStateCollection.connectToDatabase(db).then(() => { fileStateCollection.connectToDatabase(db).then(() => {
@@ -127,14 +132,17 @@
/> />
{/if} {/if}
<div <div
class="{$elevationProfile ? '' : 'h-10'} flex flex-row gap-2 px-2 sm:px-4" bind:offsetWidth={bottomPanelWidth}
class="flex {bottomPanelOrientation == 'vertical'
? 'flex-col'
: 'flex-row py-2'} gap-1 px-2"
style={$elevationProfile ? `height: ${$bottomPanelSize}px` : ''} style={$elevationProfile ? `height: ${$bottomPanelSize}px` : ''}
> >
<GPXStatistics <GPXStatistics
{gpxStatistics} {gpxStatistics}
{slicedGPXStatistics} {slicedGPXStatistics}
panelSize={$bottomPanelSize} panelSize={$bottomPanelSize}
orientation={$elevationProfile ? 'vertical' : 'horizontal'} orientation={bottomPanelOrientation == 'horizontal' ? 'vertical' : 'horizontal'}
/> />
{#if $elevationProfile} {#if $elevationProfile}
<ElevationProfile <ElevationProfile