支持图源切换
This commit is contained in:
@@ -27,6 +27,18 @@ func registerMapSourceRoutes(r gin.IRouter, store *store) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"item": publicMapTileSourceDTO(*row)})
|
c.JSON(http.StatusOK, gin.H{"item": publicMapTileSourceDTO(*row)})
|
||||||
})
|
})
|
||||||
|
r.GET("/map-source/enabled", func(c *gin.Context) {
|
||||||
|
rows, err := store.ListEnabledMapTileSources()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]gin.H, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, publicMapTileSourceDTO(row))
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAdminMapSourceRoutes(r gin.IRouter, store *store) {
|
func registerAdminMapSourceRoutes(r gin.IRouter, store *store) {
|
||||||
|
|||||||
@@ -52,6 +52,22 @@ func (s *store) CountMapTileSources(opts listOptions) (int64, error) {
|
|||||||
return total, s.db.Model(&mapTileSourceRecord{}).Count(&total).Error
|
return total, s.db.Model(&mapTileSourceRecord{}).Count(&total).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *store) ListEnabledMapTileSources() ([]mapTileSourceRecord, error) {
|
||||||
|
var rows []mapTileSourceRecord
|
||||||
|
if err := s.db.Model(&mapTileSourceRecord{}).
|
||||||
|
Where("enabled = ?", true).
|
||||||
|
Order("is_default DESC").
|
||||||
|
Order("updated_at DESC").
|
||||||
|
Order("id DESC").
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return []mapTileSourceRecord{defaultMapTileSourceRecord()}, nil
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *store) GetDefaultMapTileSource() (*mapTileSourceRecord, error) {
|
func (s *store) GetDefaultMapTileSource() (*mapTileSourceRecord, error) {
|
||||||
var row mapTileSourceRecord
|
var row mapTileSourceRecord
|
||||||
err := s.db.Where("enabled = ? AND is_default = ?", true, true).Order("id ASC").Take(&row).Error
|
err := s.db.Where("enabled = ? AND is_default = ?", true, true).Order("id ASC").Take(&row).Error
|
||||||
|
|||||||
@@ -33,6 +33,42 @@ func TestCreateMapTileSourceValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListEnabledMapTileSources(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
disabled, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: false})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateMapTileSource(disabled) error = %v", err)
|
||||||
|
}
|
||||||
|
custom, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://custom.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateMapTileSource(custom) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.SetDefaultMapTileSource(custom.ID); err != nil {
|
||||||
|
t.Fatalf("SetDefaultMapTileSource() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := st.ListEnabledMapTileSources()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListEnabledMapTileSources() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) < 2 {
|
||||||
|
t.Fatalf("ListEnabledMapTileSources() length = %d, want at least 2", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].ID != custom.ID {
|
||||||
|
t.Fatalf("first enabled source id = %d, want default %d", rows[0].ID, custom.ID)
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.ID == disabled.ID {
|
||||||
|
t.Fatalf("disabled source was returned: %+v", row)
|
||||||
|
}
|
||||||
|
if !row.Enabled {
|
||||||
|
t.Fatalf("disabled row returned: %+v", row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMapTileSourceDuplicateAndDefaultRules(t *testing.T) {
|
func TestMapTileSourceDuplicateAndDefaultRules(t *testing.T) {
|
||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import HelpPage from './components/HelpPage.vue'
|
|||||||
import MeshMap from './components/MeshMap.vue'
|
import MeshMap from './components/MeshMap.vue'
|
||||||
import NodeDetailedPage from './components/NodeDetailedPage.vue'
|
import NodeDetailedPage from './components/NodeDetailedPage.vue'
|
||||||
import NodeListPanel from './components/NodeListPanel.vue'
|
import NodeListPanel from './components/NodeListPanel.vue'
|
||||||
import { fallbackMapSource, loadDefaultMapSource } from './mapSource'
|
import { fallbackMapSource, loadEnabledMapSources } from './mapSource'
|
||||||
import type { AdminUser, HealthStatus, MapBoundsChangePayload, MapBoundsQuery, MapRenderable, MapViewportItem, NodeInfo, NodeInfoById, PositionRecord, PublicMapTileSource, TextMessage } from './types'
|
import type { AdminUser, HealthStatus, MapBoundsChangePayload, MapBoundsQuery, MapRenderable, MapViewportItem, NodeInfo, NodeInfoById, PositionRecord, PublicMapTileSource, TextMessage } from './types'
|
||||||
|
|
||||||
const currentPath = window.location.pathname
|
const currentPath = window.location.pathname
|
||||||
@@ -52,6 +52,7 @@ const currentMapBounds = ref<MapBoundsQuery | null>(null)
|
|||||||
const currentMapZoom = ref(2)
|
const currentMapZoom = ref(2)
|
||||||
const mapReportsLoading = ref(false)
|
const mapReportsLoading = ref(false)
|
||||||
const mapReportTotal = ref(0)
|
const mapReportTotal = ref(0)
|
||||||
|
const mapSources = ref<PublicMapTileSource[]>([fallbackMapSource])
|
||||||
const mapSource = ref<PublicMapTileSource>(fallbackMapSource)
|
const mapSource = ref<PublicMapTileSource>(fallbackMapSource)
|
||||||
const pendingDeleteAction = ref<PendingDeleteAction | null>(null)
|
const pendingDeleteAction = ref<PendingDeleteAction | null>(null)
|
||||||
type DeletableTextMessage = TextMessage & { mergedCount?: number; mergedMessages?: TextMessage[] }
|
type DeletableTextMessage = TextMessage & { mergedCount?: number; mergedMessages?: TextMessage[] }
|
||||||
@@ -296,7 +297,16 @@ async function refresh(showLoading = true) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadMapSource() {
|
async function loadMapSource() {
|
||||||
mapSource.value = await loadDefaultMapSource()
|
const sources = await loadEnabledMapSources()
|
||||||
|
mapSources.value = sources
|
||||||
|
mapSource.value = sources[0] ?? fallbackMapSource
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMapSource(sourceId: number) {
|
||||||
|
const source = mapSources.value.find((item) => item.id === sourceId)
|
||||||
|
if (source) {
|
||||||
|
mapSource.value = source
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkAdminSession() {
|
async function checkAdminSession() {
|
||||||
@@ -581,6 +591,8 @@ onBeforeUnmount(() => {
|
|||||||
:auto-fit="false"
|
:auto-fit="false"
|
||||||
:loading="mapReportsLoading"
|
:loading="mapReportsLoading"
|
||||||
:map-source="mapSource"
|
:map-source="mapSource"
|
||||||
|
:map-sources="mapSources"
|
||||||
|
@map-source-change="selectMapSource"
|
||||||
@bounds-change="handleMapBoundsChange"
|
@bounds-change="handleMapBoundsChange"
|
||||||
@select-node="selectedNodeId = $event"
|
@select-node="selectedNodeId = $event"
|
||||||
@clear-node="selectedNodeId = null"
|
@clear-node="selectedNodeId = null"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import type {
|
|||||||
NodeInfo,
|
NodeInfo,
|
||||||
PositionRecord,
|
PositionRecord,
|
||||||
PublicMapTileSourceResponse,
|
PublicMapTileSourceResponse,
|
||||||
|
PublicMapTileSourcesResponse,
|
||||||
TelemetryRecord,
|
TelemetryRecord,
|
||||||
TextMessage,
|
TextMessage,
|
||||||
} from './types'
|
} from './types'
|
||||||
@@ -133,6 +134,10 @@ export function getDefaultMapSource(): Promise<PublicMapTileSourceResponse> {
|
|||||||
return getJSON<PublicMapTileSourceResponse>('/api/map-source/default')
|
return getJSON<PublicMapTileSourceResponse>('/api/map-source/default')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getEnabledMapSources(): Promise<PublicMapTileSourcesResponse> {
|
||||||
|
return getJSON<PublicMapTileSourcesResponse>('/api/map-source/enabled')
|
||||||
|
}
|
||||||
|
|
||||||
export function getTextMessages(limit = 100, offset = 0, nodeId = ''): Promise<ListResponse<TextMessage>> {
|
export function getTextMessages(limit = 100, offset = 0, nodeId = ''): Promise<ListResponse<TextMessage>> {
|
||||||
return getJSON<ListResponse<TextMessage>>(listPath('/api/text-messages', limit, offset, nodeId))
|
return getJSON<ListResponse<TextMessage>>(listPath('/api/text-messages', limit, offset, nodeId))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ const props = withDefaults(defineProps<{
|
|||||||
autoFit?: boolean
|
autoFit?: boolean
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
mapSource?: PublicMapTileSource
|
mapSource?: PublicMapTileSource
|
||||||
|
mapSources?: PublicMapTileSource[]
|
||||||
}>(), {
|
}>(), {
|
||||||
autoFit: true,
|
autoFit: true,
|
||||||
loading: false,
|
loading: false,
|
||||||
mapSource: () => fallbackMapSource,
|
mapSource: () => fallbackMapSource,
|
||||||
|
mapSources: () => [fallbackMapSource],
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -24,6 +26,7 @@ const emit = defineEmits<{
|
|||||||
'delete-node': [nodeId: string]
|
'delete-node': [nodeId: string]
|
||||||
'delete-and-block-node': [payload: { nodeId: string; nodeNum: number | null }]
|
'delete-and-block-node': [payload: { nodeId: string; nodeNum: number | null }]
|
||||||
'bounds-change': [payload: MapBoundsChangePayload]
|
'bounds-change': [payload: MapBoundsChangePayload]
|
||||||
|
'map-source-change': [sourceId: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const mapEl = ref<HTMLElement | null>(null)
|
const mapEl = ref<HTMLElement | null>(null)
|
||||||
@@ -92,6 +95,10 @@ watch(
|
|||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function selectMapSource(sourceId: number) {
|
||||||
|
emit('map-source-change', sourceId)
|
||||||
|
}
|
||||||
|
|
||||||
function applyTileLayer() {
|
function applyTileLayer() {
|
||||||
if (!map) {
|
if (!map) {
|
||||||
return
|
return
|
||||||
@@ -392,6 +399,43 @@ function escapeHTML(value: string): string {
|
|||||||
<template>
|
<template>
|
||||||
<section class="map-panel panel">
|
<section class="map-panel panel">
|
||||||
<div ref="mapEl" class="map-container"></div>
|
<div ref="mapEl" class="map-container"></div>
|
||||||
|
<div
|
||||||
|
class="map-source-control"
|
||||||
|
@click.stop
|
||||||
|
@mousedown.stop
|
||||||
|
@dblclick.stop
|
||||||
|
@wheel.stop
|
||||||
|
>
|
||||||
|
<button class="map-source-icon" type="button" aria-label="切换地图图源">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M12 18.5l-3 -1.5l-6 3v-13l6 -3l6 3l6 -3v7.5" />
|
||||||
|
<path d="M9 4v13" />
|
||||||
|
<path d="M15 7v5.5" />
|
||||||
|
<path d="M21.121 20.121a3 3 0 1 0 -4.242 0c.418 .419 1.125 1.045 2.121 1.879c1.051 -.89 1.759 -1.516 2.121 -1.879" />
|
||||||
|
<path d="M19 18v.01" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="map-source-popover">
|
||||||
|
<div class="map-source-drawer-header">
|
||||||
|
<span>地图图源</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="mapSources.length > 1" class="map-source-options">
|
||||||
|
<button
|
||||||
|
v-for="source in mapSources"
|
||||||
|
:key="source.id"
|
||||||
|
class="map-source-option"
|
||||||
|
:class="{ active: source.id === mapSource.id }"
|
||||||
|
type="button"
|
||||||
|
@click="selectMapSource(source.id)"
|
||||||
|
>
|
||||||
|
<span class="map-source-option-name">{{ source.name }}</span>
|
||||||
|
<span v-if="source.id === mapSource.id" class="map-source-option-check">当前</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span v-else class="map-source-control-pill">{{ mapSource.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- <div v-if="loading" class="map-empty">正在加载当前区域坐标...</div>
|
<!-- <div v-if="loading" class="map-empty">正在加载当前区域坐标...</div>
|
||||||
<div v-else-if="items.length === 0" class="map-empty">暂无可显示坐标的节点</div> -->
|
<div v-else-if="items.length === 0" class="map-empty">暂无可显示坐标的节点</div> -->
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { createNodeBlockingRule, deleteNode, deleteTextMessage, getMapReportById, getNodeInfoById, getPositions, getTelemetry, getTextMessages } from '../api'
|
import { createNodeBlockingRule, deleteNode, deleteTextMessage, getMapReportById, getNodeInfoById, getPositions, getTelemetry, getTextMessages } from '../api'
|
||||||
import type { MapReport, NodeInfo, PositionRecord, PublicMapTileSource, TelemetryRecord, TextMessage } from '../types'
|
import type { MapReport, NodeInfo, PositionRecord, PublicMapTileSource, TelemetryRecord, TextMessage } from '../types'
|
||||||
import { fallbackMapSource, loadDefaultMapSource } from '../mapSource'
|
import { fallbackMapSource, loadEnabledMapSources } from '../mapSource'
|
||||||
import ConfirmDeleteModal from './ConfirmDeleteModal.vue'
|
import ConfirmDeleteModal from './ConfirmDeleteModal.vue'
|
||||||
import NodeTrajectoryMap from './NodeTrajectoryMap.vue'
|
import NodeTrajectoryMap from './NodeTrajectoryMap.vue'
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ const mapReport = ref<MapReport | null>(null)
|
|||||||
const messages = ref<TextMessage[]>([])
|
const messages = ref<TextMessage[]>([])
|
||||||
const positions = ref<PositionRecord[]>([])
|
const positions = ref<PositionRecord[]>([])
|
||||||
const telemetry = ref<TelemetryRecord[]>([])
|
const telemetry = ref<TelemetryRecord[]>([])
|
||||||
|
const mapSources = ref<PublicMapTileSource[]>([fallbackMapSource])
|
||||||
const mapSource = ref<PublicMapTileSource>(fallbackMapSource)
|
const mapSource = ref<PublicMapTileSource>(fallbackMapSource)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const chatLoadingOlder = ref(false)
|
const chatLoadingOlder = ref(false)
|
||||||
@@ -370,7 +371,16 @@ function handleChatScroll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadMapSource() {
|
async function loadMapSource() {
|
||||||
mapSource.value = await loadDefaultMapSource()
|
const sources = await loadEnabledMapSources()
|
||||||
|
mapSources.value = sources
|
||||||
|
mapSource.value = sources[0] ?? fallbackMapSource
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMapSource(sourceId: number) {
|
||||||
|
const source = mapSources.value.find((item) => item.id === sourceId)
|
||||||
|
if (source) {
|
||||||
|
mapSource.value = source
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDetails() {
|
async function loadDetails() {
|
||||||
@@ -499,7 +509,12 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<span class="badge">{{ positions.length }}</span>
|
<span class="badge">{{ positions.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<NodeTrajectoryMap :positions="positions" :map-source="mapSource" />
|
<NodeTrajectoryMap
|
||||||
|
:positions="positions"
|
||||||
|
:map-source="mapSource"
|
||||||
|
:map-sources="mapSources"
|
||||||
|
@map-source-change="selectMapSource"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,25 @@ import type { PositionRecord, PublicMapTileSource } from '../types'
|
|||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
positions: PositionRecord[]
|
positions: PositionRecord[]
|
||||||
mapSource?: PublicMapTileSource
|
mapSource?: PublicMapTileSource
|
||||||
|
mapSources?: PublicMapTileSource[]
|
||||||
}>(), {
|
}>(), {
|
||||||
mapSource: () => fallbackMapSource,
|
mapSource: () => fallbackMapSource,
|
||||||
|
mapSources: () => [fallbackMapSource],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'map-source-change': [sourceId: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
const mapEl = ref<HTMLElement | null>(null)
|
const mapEl = ref<HTMLElement | null>(null)
|
||||||
let map: L.Map | null = null
|
let map: L.Map | null = null
|
||||||
let tileLayer: L.TileLayer | null = null
|
let tileLayer: L.TileLayer | null = null
|
||||||
let layer: L.LayerGroup | null = null
|
let layer: L.LayerGroup | null = null
|
||||||
|
|
||||||
|
function selectMapSource(sourceId: number) {
|
||||||
|
emit('map-source-change', sourceId)
|
||||||
|
}
|
||||||
|
|
||||||
function applyTileLayer() {
|
function applyTileLayer() {
|
||||||
if (!map) {
|
if (!map) {
|
||||||
return
|
return
|
||||||
@@ -93,5 +103,44 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<div class="trajectory-map-shell">
|
||||||
<div ref="mapEl" class="trajectory-map"></div>
|
<div ref="mapEl" class="trajectory-map"></div>
|
||||||
|
<div
|
||||||
|
class="map-source-control"
|
||||||
|
@click.stop
|
||||||
|
@mousedown.stop
|
||||||
|
@dblclick.stop
|
||||||
|
@wheel.stop
|
||||||
|
>
|
||||||
|
<button class="map-source-icon" type="button" aria-label="切换地图图源">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M12 18.5l-3 -1.5l-6 3v-13l6 -3l6 3l6 -3v7.5" />
|
||||||
|
<path d="M9 4v13" />
|
||||||
|
<path d="M15 7v5.5" />
|
||||||
|
<path d="M21.121 20.121a3 3 0 1 0 -4.242 0c.418 .419 1.125 1.045 2.121 1.879c1.051 -.89 1.759 -1.516 2.121 -1.879" />
|
||||||
|
<path d="M19 18v.01" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="map-source-popover">
|
||||||
|
<div class="map-source-drawer-header">
|
||||||
|
<span>地图图源</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="mapSources.length > 1" class="map-source-options">
|
||||||
|
<button
|
||||||
|
v-for="source in mapSources"
|
||||||
|
:key="source.id"
|
||||||
|
class="map-source-option"
|
||||||
|
:class="{ active: source.id === mapSource.id }"
|
||||||
|
type="button"
|
||||||
|
@click="selectMapSource(source.id)"
|
||||||
|
>
|
||||||
|
<span class="map-source-option-name">{{ source.name }}</span>
|
||||||
|
<span v-if="source.id === mapSource.id" class="map-source-option-check">当前</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span v-else class="map-source-control-pill">{{ mapSource.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getDefaultMapSource } from './api'
|
import { getDefaultMapSource, getEnabledMapSources } from './api'
|
||||||
import type { PublicMapTileSource } from './types'
|
import type { PublicMapTileSource } from './types'
|
||||||
|
|
||||||
export const fallbackMapSource: PublicMapTileSource = {
|
export const fallbackMapSource: PublicMapTileSource = {
|
||||||
@@ -17,3 +17,12 @@ export async function loadDefaultMapSource(): Promise<PublicMapTileSource> {
|
|||||||
return fallbackMapSource
|
return fallbackMapSource
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadEnabledMapSources(): Promise<PublicMapTileSource[]> {
|
||||||
|
try {
|
||||||
|
const response = await getEnabledMapSources()
|
||||||
|
return response.items.length > 0 ? response.items : [fallbackMapSource]
|
||||||
|
} catch {
|
||||||
|
return [fallbackMapSource]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -405,6 +405,151 @@ h3 {
|
|||||||
min-height: 560px;
|
min-height: 560px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-source-control {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 800;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.92);
|
||||||
|
border-radius: 14px;
|
||||||
|
color: #1d4ed8;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.14);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
width: min(300px, calc(100vw - 32px));
|
||||||
|
border: 1px solid rgba(191, 219, 254, 0.95);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 12px;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(239, 246, 255, 0.97) 100%);
|
||||||
|
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.18);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(-6px) scale(0.98);
|
||||||
|
transform-origin: top right;
|
||||||
|
transition: opacity 0.16s ease, transform 0.16s ease;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-control:hover .map-source-popover,
|
||||||
|
.map-source-control:focus-within .map-source-popover {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-control:hover .map-source-icon,
|
||||||
|
.map-source-control:focus-within .map-source-icon {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-drawer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid #dbeafe;
|
||||||
|
padding-bottom: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-drawer-header span {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-drawer-header small {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
background: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-options {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px 11px;
|
||||||
|
color: #334155;
|
||||||
|
text-align: left;
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-option:hover {
|
||||||
|
border-color: #93c5fd;
|
||||||
|
background: #f8fbff;
|
||||||
|
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.12);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-option.active {
|
||||||
|
border-color: #2563eb;
|
||||||
|
color: #1d4ed8;
|
||||||
|
background: #eff6ff;
|
||||||
|
box-shadow: inset 3px 0 0 #2563eb, 0 8px 18px rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-option-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-option-check {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
color: #166534;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-source-control-pill {
|
||||||
|
max-width: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.map-empty {
|
.map-empty {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 450;
|
z-index: 450;
|
||||||
@@ -625,6 +770,10 @@ h3 {
|
|||||||
padding: 13px 16px;
|
padding: 13px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trajectory-map-shell {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.trajectory-map {
|
.trajectory-map {
|
||||||
height: 420px;
|
height: 420px;
|
||||||
min-height: 360px;
|
min-height: 360px;
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ export interface PublicMapTileSourceResponse {
|
|||||||
item: PublicMapTileSource
|
item: PublicMapTileSource
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublicMapTileSourcesResponse {
|
||||||
|
items: PublicMapTileSource[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface MapViewportPoint extends MapReport {
|
export interface MapViewportPoint extends MapReport {
|
||||||
type: 'point'
|
type: 'point'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user