Skip to content

Commit 27313d8

Browse files
committed
feat(stage-tamagotchi): added Map widget for demo
1 parent aa85b41 commit 27313d8

File tree

3 files changed

+308
-0
lines changed

3 files changed

+308
-0
lines changed

apps/stage-tamagotchi/src/renderer/pages/widgets.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ onBeforeUnmount(() => {
155155
})
156156
157157
const Registry: Record<string, ReturnType<typeof defineAsyncComponent>> = {
158+
map: defineAsyncComponent(async () => (await import('../widgets/map')).Map),
158159
weather: defineAsyncComponent(async () => (await import('../widgets/weather')).Weather),
159160
}
160161
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
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>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as Map } from './components/Map.vue'

0 commit comments

Comments
 (0)