mirror of
https://github.com/julyx10/lap.git
synced 2026-10-02 10:05:16 +08:00
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:
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1313,7 +1313,15 @@
|
||||
"左",
|
||||
"右"
|
||||
]
|
||||
}
|
||||
},
|
||||
"section_map": "地図",
|
||||
"map_marker_size": "地図マーカーのサイズ",
|
||||
"map_marker_size_options": [
|
||||
"小",
|
||||
"中",
|
||||
"大",
|
||||
"特大"
|
||||
]
|
||||
},
|
||||
"image_view": {
|
||||
"title": "ビューア",
|
||||
|
||||
@@ -1313,7 +1313,15 @@
|
||||
"왼쪽",
|
||||
"오른쪽"
|
||||
]
|
||||
}
|
||||
},
|
||||
"section_map": "지도",
|
||||
"map_marker_size": "지도 마커 크기",
|
||||
"map_marker_size_options": [
|
||||
"작게",
|
||||
"중간",
|
||||
"크게",
|
||||
"아주 크게"
|
||||
]
|
||||
},
|
||||
"image_view": {
|
||||
"title": "뷰어",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1313,7 +1313,15 @@
|
||||
"Слева",
|
||||
"Справа"
|
||||
]
|
||||
}
|
||||
},
|
||||
"section_map": "Карта",
|
||||
"map_marker_size": "Размер маркера на карте",
|
||||
"map_marker_size_options": [
|
||||
"Малый",
|
||||
"Средний",
|
||||
"Большой",
|
||||
"Очень большой"
|
||||
]
|
||||
},
|
||||
"image_view": {
|
||||
"title": "Просмотрщик",
|
||||
|
||||
@@ -1313,7 +1313,15 @@
|
||||
"左侧",
|
||||
"右侧"
|
||||
]
|
||||
}
|
||||
},
|
||||
"section_map": "地图",
|
||||
"map_marker_size": "地图标记尺寸",
|
||||
"map_marker_size_options": [
|
||||
"小",
|
||||
"中",
|
||||
"大",
|
||||
"特大"
|
||||
]
|
||||
},
|
||||
"image_view": {
|
||||
"title": "查看器",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user