|
| 1 | +<script setup lang="ts"> |
| 2 | +import { computed } from 'vue' |
| 3 | +
|
| 4 | +interface MapPoint { |
| 5 | + x: number |
| 6 | + y: number |
| 7 | + label?: string |
| 8 | +} |
| 9 | +
|
| 10 | +const props = withDefaults(defineProps<{ |
| 11 | + propsLoading?: boolean |
| 12 | + title?: string |
| 13 | + eta?: string |
| 14 | + distance?: string |
| 15 | + mode?: string |
| 16 | + status?: string |
| 17 | + originLabel?: string |
| 18 | + destinationLabel?: string |
| 19 | + origin?: MapPoint |
| 20 | + destination?: MapPoint |
| 21 | + route?: MapPoint[] |
| 22 | + stops?: MapPoint[] |
| 23 | + accent?: string |
| 24 | +}>(), { |
| 25 | + propsLoading: false, |
| 26 | + title: undefined, |
| 27 | + eta: '--', |
| 28 | + distance: '--', |
| 29 | + mode: 'Transit', |
| 30 | + status: 'Smooth ride', |
| 31 | + originLabel: 'You', |
| 32 | + destinationLabel: 'Airport', |
| 33 | + accent: '#38bdf8', |
| 34 | +}) |
| 35 | +
|
| 36 | +const fallbackOrigin: MapPoint = { x: 22, y: 72 } |
| 37 | +const fallbackDestination: MapPoint = { x: 78, y: 26 } |
| 38 | +const fallbackRoute: MapPoint[] = [ |
| 39 | + { x: 22, y: 72 }, |
| 40 | + { x: 32, y: 64 }, |
| 41 | + { x: 44, y: 58 }, |
| 42 | + { x: 52, y: 50 }, |
| 43 | + { x: 60, y: 42 }, |
| 44 | + { x: 70, y: 34 }, |
| 45 | + { x: 78, y: 26 }, |
| 46 | +] |
| 47 | +
|
| 48 | +const fallbackStops: MapPoint[] = [ |
| 49 | + { x: 32, y: 64, label: 'Midtown' }, |
| 50 | + { x: 52, y: 50, label: 'Central' }, |
| 51 | + { x: 70, y: 34, label: 'Skyline' }, |
| 52 | +] |
| 53 | +
|
| 54 | +function clamp(value: number) { |
| 55 | + return Math.min(100, Math.max(0, value)) |
| 56 | +} |
| 57 | +
|
| 58 | +function clampPoint(point: MapPoint): MapPoint { |
| 59 | + return { |
| 60 | + x: clamp(point.x), |
| 61 | + y: clamp(point.y), |
| 62 | + label: point.label, |
| 63 | + } |
| 64 | +} |
| 65 | +
|
| 66 | +const heading = computed(() => props.title ?? `To ${props.destinationLabel ?? 'Airport'}`) |
| 67 | +
|
| 68 | +const resolvedOrigin = computed(() => clampPoint(props.origin ?? fallbackOrigin)) |
| 69 | +const resolvedDestination = computed(() => clampPoint(props.destination ?? fallbackDestination)) |
| 70 | +
|
| 71 | +const routePoints = computed(() => { |
| 72 | + const points = (props.route?.length ? props.route : fallbackRoute).map(clampPoint) |
| 73 | + if (!points.length) |
| 74 | + return [resolvedOrigin.value, resolvedDestination.value] |
| 75 | +
|
| 76 | + const first = points[0] |
| 77 | + const last = points[points.length - 1] |
| 78 | + if (first.x !== resolvedOrigin.value.x || first.y !== resolvedOrigin.value.y) |
| 79 | + points.unshift(resolvedOrigin.value) |
| 80 | + if (last.x !== resolvedDestination.value.x || last.y !== resolvedDestination.value.y) |
| 81 | + points.push(resolvedDestination.value) |
| 82 | +
|
| 83 | + return points |
| 84 | +}) |
| 85 | +
|
| 86 | +const stopPoints = computed(() => (props.stops?.length ? props.stops : fallbackStops).map(clampPoint)) |
| 87 | +
|
| 88 | +const routePath = computed(() => routePoints.value |
| 89 | + .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`) |
| 90 | + .join(' ')) |
| 91 | +
|
| 92 | +const mapStyle = computed(() => ({ |
| 93 | + '--map-accent': props.accent, |
| 94 | +} as Record<string, string>)) |
| 95 | +
|
| 96 | +function pointStyle(point: MapPoint) { |
| 97 | + return { |
| 98 | + left: `${point.x}%`, |
| 99 | + top: `${point.y}%`, |
| 100 | + } |
| 101 | +} |
| 102 | +</script> |
| 103 | + |
| 104 | +<template> |
| 105 | + <div :class="['font-sans', 'relative', 'h-full', 'w-full', 'overflow-hidden', 'rounded-2xl', 'bg-neutral-950/65', 'text-neutral-100', 'shadow-[0_16px_40px_rgba(15,23,42,0.35)]']" :style="mapStyle"> |
| 106 | + <div v-if="props.propsLoading" :class="['flex', 'h-full', 'w-full', 'items-center', 'justify-center']"> |
| 107 | + <div :class="['grid', 'w-[85%]', 'gap-4']"> |
| 108 | + <div :class="['h-4', 'w-40', 'rounded-full', 'bg-white/10', 'animate-pulse']" /> |
| 109 | + <div :class="['h-7', 'w-28', 'rounded-full', 'bg-white/10', 'animate-pulse']" /> |
| 110 | + <div :class="['h-52', 'w-full', 'rounded-2xl', 'bg-white/10', 'animate-pulse']" /> |
| 111 | + <div :class="['grid', 'grid-cols-3', 'gap-2']"> |
| 112 | + <div v-for="index in 3" :key="index" :class="['h-10', 'rounded-xl', 'bg-white/10', 'animate-pulse']" /> |
| 113 | + </div> |
| 114 | + </div> |
| 115 | + </div> |
| 116 | + |
| 117 | + <div v-else :class="['relative', 'grid', 'h-full', 'grid-rows-[auto_minmax(0,1fr)_auto]', 'gap-3', 'p-4']"> |
| 118 | + <div :class="['flex', 'items-start', 'justify-between', 'gap-4']"> |
| 119 | + <div> |
| 120 | + <div :class="['flex', 'items-center', 'gap-2', 'text-lg', 'font-semibold']"> |
| 121 | + <span :class="['i-lucide-plane-landing', 'text-base', 'opacity-80']" /> |
| 122 | + <span :class="['truncate']">{{ heading }}</span> |
| 123 | + </div> |
| 124 | + <div :class="['mt-1', 'flex', 'items-center', 'gap-2', 'text-xs', 'text-neutral-300/80']"> |
| 125 | + <span :class="['inline-flex', 'items-center', 'gap-1', 'rounded-full', 'bg-white/10', 'px-2', 'py-0.5']"> |
| 126 | + <span :class="['i-lucide-route', 'text-[0.7rem]']" /> |
| 127 | + <span>{{ props.mode }}</span> |
| 128 | + </span> |
| 129 | + <span :class="['truncate']">{{ props.status }}</span> |
| 130 | + </div> |
| 131 | + </div> |
| 132 | + <div :class="['text-right']"> |
| 133 | + <div :class="['text-xs', 'uppercase', 'tracking-[0.2em]', 'text-neutral-400']"> |
| 134 | + ETA |
| 135 | + </div> |
| 136 | + <div :class="['text-3xl', 'font-semibold', 'leading-none']"> |
| 137 | + {{ props.eta }} |
| 138 | + </div> |
| 139 | + </div> |
| 140 | + </div> |
| 141 | + |
| 142 | + <div :class="['relative', 'overflow-hidden', 'rounded-2xl', 'border', 'border-white/10', 'bg-neutral-900/60']"> |
| 143 | + <div class="map-surface" /> |
| 144 | + <div class="map-grid" /> |
| 145 | + <div class="map-glow" /> |
| 146 | + |
| 147 | + <svg |
| 148 | + :class="['absolute', 'inset-0', 'h-full', 'w-full']" |
| 149 | + viewBox="0 0 100 100" |
| 150 | + preserveAspectRatio="none" |
| 151 | + > |
| 152 | + <path |
| 153 | + class="map-route map-route-shadow" |
| 154 | + :d="routePath" |
| 155 | + /> |
| 156 | + <path |
| 157 | + class="map-route map-route-core" |
| 158 | + :d="routePath" |
| 159 | + /> |
| 160 | + <g> |
| 161 | + <circle |
| 162 | + v-for="(stop, index) in stopPoints" |
| 163 | + :key="index" |
| 164 | + :cx="stop.x" |
| 165 | + :cy="stop.y" |
| 166 | + r="1.3" |
| 167 | + class="map-stop" |
| 168 | + /> |
| 169 | + </g> |
| 170 | + </svg> |
| 171 | + |
| 172 | + <div :class="['absolute', 'inset-0']"> |
| 173 | + <div |
| 174 | + class="map-marker map-marker--origin" |
| 175 | + :style="pointStyle(resolvedOrigin)" |
| 176 | + > |
| 177 | + <span :class="['i-lucide-user', 'text-[0.55rem]']" /> |
| 178 | + </div> |
| 179 | + <div |
| 180 | + class="map-marker map-marker--destination" |
| 181 | + :style="pointStyle(resolvedDestination)" |
| 182 | + > |
| 183 | + <span :class="['i-lucide-plane', 'text-[0.55rem]']" /> |
| 184 | + </div> |
| 185 | + </div> |
| 186 | + </div> |
| 187 | + |
| 188 | + <div :class="['grid', 'grid-cols-3', 'gap-2', 'text-xs']"> |
| 189 | + <div :class="['rounded-xl', 'bg-white/8', 'px-3', 'py-2', 'text-neutral-200', 'shadow-[inset_0_0_0_1px_rgba(148,163,184,0.15)]']"> |
| 190 | + <div :class="['text-[0.65rem]', 'uppercase', 'tracking-widest', 'text-neutral-400']"> |
| 191 | + Origin |
| 192 | + </div> |
| 193 | + <div :class="['mt-1', 'truncate', 'font-semibold']"> |
| 194 | + {{ props.originLabel }} |
| 195 | + </div> |
| 196 | + </div> |
| 197 | + <div :class="['rounded-xl', 'bg-white/8', 'px-3', 'py-2', 'text-neutral-200', 'shadow-[inset_0_0_0_1px_rgba(148,163,184,0.15)]']"> |
| 198 | + <div :class="['text-[0.65rem]', 'uppercase', 'tracking-widest', 'text-neutral-400']"> |
| 199 | + Destination |
| 200 | + </div> |
| 201 | + <div :class="['mt-1', 'truncate', 'font-semibold']"> |
| 202 | + {{ props.destinationLabel }} |
| 203 | + </div> |
| 204 | + </div> |
| 205 | + <div :class="['rounded-xl', 'bg-white/8', 'px-3', 'py-2', 'text-neutral-200', 'shadow-[inset_0_0_0_1px_rgba(148,163,184,0.15)]']"> |
| 206 | + <div :class="['text-[0.65rem]', 'uppercase', 'tracking-widest', 'text-neutral-400']"> |
| 207 | + Distance |
| 208 | + </div> |
| 209 | + <div :class="['mt-1', 'truncate', 'font-semibold']"> |
| 210 | + {{ props.distance }} |
| 211 | + </div> |
| 212 | + </div> |
| 213 | + </div> |
| 214 | + </div> |
| 215 | + </div> |
| 216 | +</template> |
| 217 | + |
| 218 | +<style scoped> |
| 219 | +.map-surface { |
| 220 | + position: absolute; |
| 221 | + inset: 0; |
| 222 | + background: |
| 223 | + radial-gradient(circle at 20% 20%, rgba(59, 130, 246, 0.18), transparent 45%), |
| 224 | + radial-gradient(circle at 80% 10%, rgba(34, 197, 94, 0.12), transparent 40%), |
| 225 | + radial-gradient(circle at 20% 90%, rgba(14, 165, 233, 0.2), transparent 40%), |
| 226 | + linear-gradient(135deg, rgba(15, 23, 42, 0.85), rgba(2, 6, 23, 0.95)); |
| 227 | +} |
| 228 | +
|
| 229 | +.map-grid { |
| 230 | + position: absolute; |
| 231 | + inset: -20% 0 0 -20%; |
| 232 | + background-image: |
| 233 | + linear-gradient(90deg, rgba(148, 163, 184, 0.12) 1px, transparent 1px), |
| 234 | + linear-gradient(0deg, rgba(148, 163, 184, 0.12) 1px, transparent 1px); |
| 235 | + background-size: 18px 18px; |
| 236 | + opacity: 0.4; |
| 237 | + transform: rotate(-6deg); |
| 238 | +} |
| 239 | +
|
| 240 | +.map-glow { |
| 241 | + position: absolute; |
| 242 | + inset: 0; |
| 243 | + background: radial-gradient(circle at 60% 40%, rgba(59, 130, 246, 0.2), transparent 55%); |
| 244 | + mix-blend-mode: screen; |
| 245 | + opacity: 0.8; |
| 246 | +} |
| 247 | +
|
| 248 | +.map-route { |
| 249 | + fill: none; |
| 250 | + stroke-linecap: round; |
| 251 | + stroke-linejoin: round; |
| 252 | +} |
| 253 | +
|
| 254 | +.map-route-shadow { |
| 255 | + stroke: rgba(15, 23, 42, 0.7); |
| 256 | + stroke-width: 3.2; |
| 257 | +} |
| 258 | +
|
| 259 | +.map-route-core { |
| 260 | + stroke: var(--map-accent, #38bdf8); |
| 261 | + stroke-width: 1.6; |
| 262 | + stroke-dasharray: 3 2; |
| 263 | + filter: drop-shadow(0 0 6px rgba(56, 189, 248, 0.35)); |
| 264 | +} |
| 265 | +
|
| 266 | +.map-stop { |
| 267 | + fill: rgba(248, 250, 252, 0.85); |
| 268 | + opacity: 0.9; |
| 269 | +} |
| 270 | +
|
| 271 | +.map-marker { |
| 272 | + position: absolute; |
| 273 | + display: flex; |
| 274 | + align-items: center; |
| 275 | + justify-content: center; |
| 276 | + width: 16px; |
| 277 | + height: 16px; |
| 278 | + border-radius: 999px; |
| 279 | + transform: translate(-50%, -50%); |
| 280 | + color: #0f172a; |
| 281 | +} |
| 282 | +
|
| 283 | +.map-marker--origin { |
| 284 | + background: rgba(226, 232, 240, 0.9); |
| 285 | + box-shadow: 0 0 0 4px rgba(148, 163, 184, 0.15); |
| 286 | +} |
| 287 | +
|
| 288 | +.map-marker--destination { |
| 289 | + background: var(--map-accent, #38bdf8); |
| 290 | + color: #0f172a; |
| 291 | + box-shadow: |
| 292 | + 0 0 0 4px rgba(56, 189, 248, 0.2), |
| 293 | + 0 0 16px rgba(56, 189, 248, 0.45); |
| 294 | + animation: map-pulse 2.4s ease-in-out infinite; |
| 295 | +} |
| 296 | +
|
| 297 | +@keyframes map-pulse { |
| 298 | + 0%, |
| 299 | + 100% { |
| 300 | + transform: translate(-50%, -50%) scale(1); |
| 301 | + } |
| 302 | + 50% { |
| 303 | + transform: translate(-50%, -50%) scale(1.08); |
| 304 | + } |
| 305 | +} |
| 306 | +</style> |
0 commit comments