feat: add configurable map photo marker size (#307)

- new settings.grid "Map" section with marker size preset (48/64/88/120)
- scale marker icon and cluster spacing from the configured size
- cache invalidation re-computes clustering on size change
- shared MAP_MARKER_SIZES constant + i18n across all locales
This commit is contained in:
julyx10
2026-09-16 20:07:39 +08:00
parent 64299b6b42
commit 17faa5baae
16 changed files with 140 additions and 28 deletions
+4 -4
View File
@@ -170,8 +170,8 @@ html, body {
opacity: 0;
transition: opacity var(--map-photo-fade-duration, 200ms) ease;
position: relative;
width: 64px;
height: 64px;
width: var(--map-photo-marker-size, 64px);
height: var(--map-photo-marker-size, 64px);
overflow: visible;
}
@@ -198,8 +198,8 @@ html, body {
.map-photo-marker img {
display: block;
width: 64px;
height: 64px;
width: var(--map-photo-marker-size, 64px);
height: var(--map-photo-marker-size, 64px);
aspect-ratio: 1;
border: 2px solid white;
border-radius: 6px;
+3
View File
@@ -78,3 +78,6 @@ export const SIMILAR_SCAN = {
LARGE_RESULT_THRESHOLD: 10_000,
THUMBNAIL_LIMIT: 19,
} as const;
// map photo marker size presets (px)
export const MAP_MARKER_SIZES: readonly number[] = [48, 64, 88, 120];
+8 -7
View File
@@ -1,8 +1,9 @@
// Distance-grid nearest-neighbor search, following Leaflet.markercluster:
// https://github.com/Leaflet/Leaflet.markercluster/blob/master/src/DistanceGrid.js
// Fixed photo anchors avoid centroid shifts introducing new icon collisions.
export function clusterPhotoPoints(points, project, padding = 0) {
const spacing = 48 // > hypot(64 / 2, 64 / 2): at most half overlap on one axis.
export function clusterPhotoPoints(points, project, padding = 0, spacing = 48) {
// `spacing` is the minimum pixel distance between cluster anchors; it must
// exceed half the marker diagonal to avoid overlapping icons.
const cells = new Map()
const clusters = []
const sorted = [...points].sort((a, b) => b.count - a.count || Number(a.file_id) - Number(b.file_id) || a.lat - b.lat || a.lon - b.lon)
@@ -50,11 +51,11 @@ export function clusterPhotoPoints(points, project, padding = 0) {
// Build parents from the preceding zoom's clusters, as in Supercluster:
// https://github.com/mapbox/supercluster/blob/main/index.js
// Keep all input points in the index; viewport culling happens only at rendering.
export function buildPhotoClusterIndex(points, project, maxZoom, padding = 0) {
export function buildPhotoClusterIndex(points, project, maxZoom, padding = 0, spacing = 48) {
const levels = new Map()
let children = points
for (let zoom = maxZoom; zoom >= 0; zoom--) {
children = clusterPhotoPoints(children, point => project(point, zoom), padding)
children = clusterPhotoPoints(children, point => project(point, zoom), padding, spacing)
levels.set(zoom, children)
}
return levels
@@ -81,10 +82,10 @@ export function photoClusterInView(cluster, zoom, bounds) {
export function createPhotoClusterCache() {
const sources = new Map()
return ({ source, version, points, maxZoom, zoom, padding, bounds }) => {
return ({ source, version, points, maxZoom, zoom, padding, spacing, bounds }) => {
let cached = sources.get(source)
if (!cached || cached.version !== version || cached.maxZoom !== maxZoom) {
cached = { version, maxZoom, levels: buildPhotoClusterIndex(points, projectPhotoPoint, maxZoom, padding) }
if (!cached || cached.version !== version || cached.maxZoom !== maxZoom || cached.spacing !== spacing) {
cached = { version, maxZoom, spacing, levels: buildPhotoClusterIndex(points, projectPhotoPoint, maxZoom, padding, spacing) }
sources.set(source, cached)
}
return (cached.levels.get(Math.floor(zoom)) || []).filter(cluster => photoClusterInView(cluster, zoom, bounds))
+11 -6
View File
@@ -145,6 +145,7 @@ onBeforeUnmount(() => {
})
watch(() => [config.infoPanel.mapTheme, config.settings.mapProvider, config.settings.tiandituToken], updateTheme)
watch(() => config.settings.mapMarkerSize, () => renderMarkers())
watch(() => [props.queryParams, props.querySource, props.collectionId, props.fileIds], () => {
if (!props.active) {
needsRefresh = true
@@ -325,13 +326,14 @@ function renderMarkers() {
function requestPhotoClusters() {
if (!map || !clusterWorker || !props.active || !isQueryMap.value) return
const spacing = Math.round(Number(config.settings.mapMarkerSize || 64) * 0.75)
const useDetails = zoom.value >= DETAIL_ZOOM && visibleFiles.length > 0
&& detailBounds?.contains(bufferedMapBounds(176))
const source = useDetails ? 'details' : 'overview'
const input = useDetails ? visibleFiles : points.value
const previous = clusterSources.get(source)
let cached = previous
if (!cached || cached.input !== input || cached.maxZoom !== activeMaxZoom.value) {
if (!cached || cached.input !== input || cached.maxZoom !== activeMaxZoom.value || cached.spacing !== spacing) {
const candidates = useDetails
? visibleFiles.filter(file => validLatLon(file.gps_latitude, file.gps_longitude)).map(file => ({
lat: Number(file.gps_latitude), lon: Number(file.gps_longitude), file_id: Number(file.id), count: 1,
@@ -340,9 +342,9 @@ function requestPhotoClusters() {
: points.value.map(point => ({ lat: point.lat, lon: point.lon, file_id: point.file_id, count: point.count, pointIds: [point.file_id] }))
const signature = useDetails ? JSON.stringify(candidates) : null
const unchanged = cached && cached.maxZoom === activeMaxZoom.value
&& useDetails && cached.signature === signature
&& cached.spacing === spacing && useDetails && cached.signature === signature
cached = {
input, signature, maxZoom: activeMaxZoom.value,
input, signature, maxZoom: activeMaxZoom.value, spacing,
version: unchanged ? cached.version : (cached?.version || 0) + 1,
candidates,
}
@@ -353,6 +355,7 @@ function requestPhotoClusters() {
requestId: clusterRequestId, source, version: cached.version,
points: previous?.version === cached.version ? undefined : cached.candidates,
maxZoom: cached.maxZoom, zoom: map.getZoom(), padding: useDetails ? 0.0001 : 0.01,
spacing,
bounds: { min: { x: pixelBounds.min.x, y: pixelBounds.min.y }, max: { x: pixelBounds.max.x, y: pixelBounds.max.y } },
})
}
@@ -360,7 +363,7 @@ function requestPhotoClusters() {
function applyPhotoClusters(clusters) {
const nextMarkers = new Map()
for (const cluster of clusters) {
const key = JSON.stringify([cluster, config.settings.thumbnailSize])
const key = JSON.stringify([cluster, config.settings.thumbnailSize, config.settings.mapMarkerSize])
const marker = photoMarkers.get(key) || addPhotoMarker(cluster.lat, cluster.lon, cluster.file_id, cluster.count, cluster)
nextMarkers.set(key, marker)
}
@@ -414,15 +417,17 @@ async function openPhotoCluster(cluster) {
function addPhotoMarker(lat, lon, fileId, count, cluster = null) {
if (lat == null || lon == null) return
const size = Number(config.settings.mapMarkerSize || 64);
const icon = L.divIcon({
className: 'map-photo-marker-wrapper',
iconSize: [64, 72],
iconAnchor: [32, 72],
iconSize: [size, size + 8],
iconAnchor: [Math.round(size / 2), size + 8],
html: `<div class="map-photo-marker"><img src="${getThumbUrl(fileId, false, config.settings.thumbnailSize || 512)}" />${count > 1 ? `<span>${count > 999 ? '999+' : count}</span>` : ''}</div>`,
})
const marker = L.marker([lat, lon], { icon, keyboard: false }).addTo(markerLayer)
const element = marker.getElement()
element?.style.setProperty('--map-photo-fade-duration', `${MARKER_FADE_MS}ms`)
element?.style.setProperty('--map-photo-marker-size', `${size}px`)
// Wait for the photo itself: otherwise its opacity animation can finish
// while the thumbnail request is still pending.
const show = () => {
+9 -1
View File
@@ -1313,7 +1313,15 @@
"Links",
"Rechts"
]
}
},
"section_map": "Karte",
"map_marker_size": "Größe der Kartenmarkierung",
"map_marker_size_options": [
"Klein",
"Mittel",
"Groß",
"Extra groß"
]
},
"image_view": {
"title": "Viewer",
+9 -1
View File
@@ -1313,7 +1313,15 @@
"Left",
"Right"
]
}
},
"section_map": "Map",
"map_marker_size": "Map marker size",
"map_marker_size_options": [
"Small",
"Medium",
"Large",
"Extra large"
]
},
"image_view": {
"title": "Viewer",
+9 -1
View File
@@ -1313,7 +1313,15 @@
"Izquierda",
"Derecha"
]
}
},
"section_map": "Mapa",
"map_marker_size": "Tamaño del marcador del mapa",
"map_marker_size_options": [
"Pequeño",
"Mediano",
"Grande",
"Extra grande"
]
},
"image_view": {
"title": "Visualizador",
+9 -1
View File
@@ -1313,7 +1313,15 @@
"Gauche",
"Droite"
]
}
},
"section_map": "Carte",
"map_marker_size": "Taille du marqueur de carte",
"map_marker_size_options": [
"Petit",
"Moyen",
"Grand",
"Très grand"
]
},
"image_view": {
"title": "Visionneuse",
+9 -1
View File
@@ -1313,7 +1313,15 @@
"左",
"右"
]
}
},
"section_map": "地図",
"map_marker_size": "地図マーカーのサイズ",
"map_marker_size_options": [
"小",
"中",
"大",
"特大"
]
},
"image_view": {
"title": "ビューア",
+9 -1
View File
@@ -1313,7 +1313,15 @@
"왼쪽",
"오른쪽"
]
}
},
"section_map": "지도",
"map_marker_size": "지도 마커 크기",
"map_marker_size_options": [
"작게",
"중간",
"크게",
"아주 크게"
]
},
"image_view": {
"title": "뷰어",
+9 -1
View File
@@ -1313,7 +1313,15 @@
"À esquerda",
"À direita"
]
}
},
"section_map": "Mapa",
"map_marker_size": "Tamanho do marcador do mapa",
"map_marker_size_options": [
"Pequeno",
"Médio",
"Grande",
"Extra grande"
]
},
"image_view": {
"title": "Visualizador",
+9 -1
View File
@@ -1313,7 +1313,15 @@
"Слева",
"Справа"
]
}
},
"section_map": "Карта",
"map_marker_size": "Размер маркера на карте",
"map_marker_size_options": [
"Малый",
"Средний",
"Большой",
"Очень большой"
]
},
"image_view": {
"title": "Просмотрщик",
+9 -1
View File
@@ -1313,7 +1313,15 @@
"左侧",
"右侧"
]
}
},
"section_map": "地图",
"map_marker_size": "地图标记尺寸",
"map_marker_size_options": [
"小",
"中",
"大",
"特大"
]
},
"image_view": {
"title": "查看器",
+3
View File
@@ -137,6 +137,9 @@ if (isMainWindow) {
listen('settings-tiandituToken-changed', (event) => {
config.setTiandituToken(event.payload)
})
listen('settings-mapMarkerSize-changed', (event) => {
config.setMapMarkerSize(event.payload)
})
listen('settings-gridStyle-changed', (event) => {
config.setGridStyle(event.payload)
})
+6 -1
View File
@@ -2,7 +2,7 @@
* Config Store - Global application configuration
*/
import { defineStore } from 'pinia';
import { SIDEBAR } from '@/common/constants';
import { SIDEBAR, MAP_MARKER_SIZES } from '@/common/constants';
export const useConfigStore = defineStore('configStore', {
state: () => ({
@@ -125,6 +125,7 @@ export const useConfigStore = defineStore('configStore', {
rawThumbnailSource: 'processed', // processed | embedded
mapProvider: 'global', // global | tianditu
tiandituToken: '',
mapMarkerSize: 64, // map photo marker size in px
grid: {
sizePosition: 0, // grid size slider position (0-1)
style: 0, // 0: card view, 1: tile view, 2: justified view, 3: masonry view
@@ -295,6 +296,10 @@ export const useConfigStore = defineStore('configStore', {
setTiandituToken(tiandituToken) {
this.settings.tiandituToken = String(tiandituToken || '').trim();
},
setMapMarkerSize(mapMarkerSize) {
const size = Number(mapMarkerSize);
this.settings.mapMarkerSize = MAP_MARKER_SIZES.includes(size) ? size : 64;
},
setGridStyle(gridStyle) {
this.settings.grid.style = gridStyle;
},
+24 -1
View File
@@ -190,6 +190,21 @@
</div>
</div>
<!-- map -->
<div class="rounded-box p-2 space-y-2 bg-base-300/30 border border-base-content/5 shadow-sm">
<div class="flex items-center gap-2 text-base-content/30">
<span class="font-bold uppercase text-[10px] tracking-widest">{{ $t('settings.grid.section_map') }}</span>
</div>
<div class="flex items-center justify-between px-1 rounded-box hover:bg-base-100/10 transition-colors duration-200">
<div class="flex flex-col gap-0.5 text-sm leading-5">
<div>{{ $t('settings.grid.map_marker_size') }}</div>
</div>
<select class="select select-bordered select-sm min-w-32" v-model="config.settings.mapMarkerSize">
<option v-for="(option, index) in mapMarkerSizeOptions" :key="index" :value="option.value">{{ option.label }}</option>
</select>
</div>
</div>
</div>
<!-- Viewer Tab -->
@@ -703,7 +718,7 @@ import { ask, open as openDialog } from '@tauri-apps/plugin-dialog';
import { useI18n } from 'vue-i18n';
import { config, libConfig } from '@/common/config';
import { normalizeThumbnailSize } from '@/common/thumbnailProfiles';
import { THUMBNAIL_BADGE } from '@/common/constants';
import { THUMBNAIL_BADGE, MAP_MARKER_SIZES } from '@/common/constants';
import {
getDbStorageDir,
changeDbStorageDir,
@@ -998,6 +1013,11 @@ const gridStyleOptions = computed(() => {
return result;
});
const mapMarkerSizeOptions = computed(() => {
const labels = localeMsg.value.settings.grid.map_marker_size_options;
return MAP_MARKER_SIZES.map((size, index) => ({ label: labels[index], value: size }));
});
// Define the grid label options
const gridLabelOptions = computed(() => {
const options = localeMsg.value.settings.grid.label_options;
@@ -1555,6 +1575,9 @@ watch(() => config.settings.grid.thumbnailBadge, (newValue) => {
watch(() => config.settings.grid.previewPosition, (newValue) => {
emit('settings-filmStripViewPreviewPosition-changed', newValue);
});
watch(() => config.settings.mapMarkerSize, (newValue) => {
emit('settings-mapMarkerSize-changed', newValue);
});
// image viewer settings
watch(() => config.settings.mouseWheelMode, (newValue) => {
emit('settings-mouseWheelMode-changed', newValue);