map.ts 4.9 KB

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