map.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. 'use client'
  2. import L from 'leaflet'
  3. import 'leaflet/dist/leaflet.css'
  4. export interface MapBounds {
  5. swLat: number
  6. swLng: number
  7. neLat: number
  8. neLng: number
  9. }
  10. export interface MarkerOptions {
  11. type?: 'place' | 'studio' | 'visitor' | 'location'
  12. title?: string
  13. onClick?: () => void
  14. ghost?: boolean
  15. }
  16. const TILE_URL = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
  17. const MARKER_COLORS: Record<string, string> = {
  18. place: '#3b82f6',
  19. studio: '#a855f7',
  20. visitor: '#22c55e',
  21. location: '#3b82f6',
  22. }
  23. function createIcon(type: string, size: number, ghost?: boolean): L.DivIcon {
  24. const color = ghost
  25. ? 'radial-gradient(circle, rgba(255, 229, 0, 0.8) 0%, rgba(255, 229, 0, 0) 75%)'
  26. : MARKER_COLORS[type] || '#3b82f6'
  27. if (type === 'location') {
  28. const locStyle = ghost
  29. ? `width:10px;height:10px;background:radial-gradient(circle, rgba(255, 229, 0, 0.8) 0%, rgba(255, 229, 0, 0) 75%);border-radius:50%`
  30. : `width:12px;height:12px;background:#3b82f6;border-radius:50%;border:3px solid white;box-shadow:0 0 0 4px rgba(59,130,246,0.3)`
  31. const s = ghost ? 10 : 20
  32. return L.divIcon({
  33. className: '',
  34. iconSize: [s, s],
  35. iconAnchor: [s / 2, s / 2],
  36. html: `<div style="${locStyle}"></div>`,
  37. })
  38. }
  39. const ghostSize = 10
  40. const style = ghost
  41. ? `width:${ghostSize}px;height:${ghostSize}px;background:${color};border-radius:50%`
  42. : `width:${size}px;height:${size}px;background:${color};border-radius:50%;border:${size > 20 ? '3px' : '2px'} solid white;box-shadow:0 2px 8px rgba(0,0,0,0.3)`
  43. return L.divIcon({
  44. className: '',
  45. iconSize: ghost ? [ghostSize, ghostSize] : [size, size],
  46. iconAnchor: ghost ? [ghostSize / 2, ghostSize / 2] : [size / 2, size / 2],
  47. html: `<div style="${style}"></div>`,
  48. })
  49. }
  50. export interface MapProvider {
  51. init(container: HTMLElement, center: [number, number], zoom: number): Promise<void>
  52. destroy(): void
  53. isReady(): boolean
  54. addMarker(id: string, lat: number, lng: number, options?: MarkerOptions): void
  55. removeMarker(id: string): void
  56. setCenter(lat: number, lng: number): void
  57. fitBounds(bounds: [[number, number], [number, number]]): void
  58. onMove(cb: (center: [number, number], zoom: number, bounds: MapBounds) => void): void
  59. getBounds(): MapBounds | null
  60. }
  61. export function createLeafletMapProvider(): MapProvider {
  62. let map: L.Map | null = null
  63. let markers = new Map<string, L.Marker>()
  64. let moveHandler: ((center: [number, number], zoom: number, bounds: MapBounds) => void) | null = null
  65. return {
  66. async init(container, center, zoom) {
  67. try {
  68. map = L.map(container, {
  69. zoomControl: false,
  70. attributionControl: false,
  71. }).setView([center[1], center[0]], zoom)
  72. L.tileLayer(TILE_URL, {
  73. maxZoom: 19,
  74. crossOrigin: 'anonymous',
  75. }).addTo(map)
  76. map.on('moveend', () => {
  77. if (!moveHandler || !map) return
  78. const c = map.getCenter()
  79. const b = map.getBounds()
  80. moveHandler(
  81. [c.lng, c.lat],
  82. map.getZoom(),
  83. {
  84. swLat: b.getSouthWest().lat,
  85. swLng: b.getSouthWest().lng,
  86. neLat: b.getNorthEast().lat,
  87. neLng: b.getNorthEast().lng,
  88. },
  89. )
  90. })
  91. } catch {
  92. map = null
  93. }
  94. },
  95. isReady() {
  96. return map !== null
  97. },
  98. destroy() {
  99. markers.clear()
  100. map?.remove()
  101. map = null
  102. },
  103. addMarker(id, lat, lng, options) {
  104. if (!map) return
  105. const ghost = options?.ghost
  106. const type = options?.type || 'place'
  107. const size = type === 'visitor' || type === 'location' ? 12 : 32
  108. const icon = createIcon(type, size, ghost)
  109. const interactive = !ghost
  110. const marker = L.marker([lat, lng], { icon, interactive }).addTo(map)
  111. if (options?.title && !ghost) {
  112. marker.bindTooltip(options.title, { direction: 'top' })
  113. }
  114. if (options?.onClick && !ghost) {
  115. marker.on('click', () => options.onClick?.())
  116. }
  117. markers.set(id, marker)
  118. },
  119. removeMarker(id) {
  120. const marker = markers.get(id)
  121. if (marker) {
  122. marker.remove()
  123. markers.delete(id)
  124. }
  125. },
  126. setCenter(lat, lng) {
  127. map?.setView([lat, lng])
  128. },
  129. fitBounds(bounds) {
  130. map?.fitBounds([
  131. [bounds[0][1], bounds[0][0]],
  132. [bounds[1][1], bounds[1][0]],
  133. ])
  134. },
  135. onMove(cb) {
  136. moveHandler = cb
  137. },
  138. getBounds() {
  139. if (!map) return null
  140. const b = map.getBounds()
  141. return {
  142. swLat: b.getSouthWest().lat,
  143. swLng: b.getSouthWest().lng,
  144. neLat: b.getNorthEast().lat,
  145. neLng: b.getNorthEast().lng,
  146. }
  147. },
  148. }
  149. }