map.ts 4.8 KB

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