@blogmcp/mdx-render-vue 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/composables/index.d.ts +1 -0
- package/dist/composables/index.js +6 -0
- package/dist/composables/useTripMap.d.ts +67 -0
- package/dist/composables/useTripMap.js +39 -0
- package/dist/edit/TripMapEdit.vue.d.ts +3 -0
- package/dist/edit/TripMapEdit.vue.js +51 -0
- package/dist/edit/TripMapEdit.vue2.js +4 -0
- package/dist/edit/index.d.ts +2 -0
- package/dist/edit/index.js +3 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +28 -17
- package/dist/registry.js +7 -0
- package/dist/render/index.d.ts +3 -0
- package/dist/render/index.js +46 -42
- package/dist/render/tripmap/TripMapDefault.vue.d.ts +10 -0
- package/dist/render/tripmap/TripMapDefault.vue.js +209 -0
- package/dist/render/tripmap/TripMapDefault.vue2.js +4 -0
- package/dist/render/tripmap/index.d.ts +5 -0
- package/dist/render/tripmap/index.js +5 -0
- package/dist/useTripMap.d.ts +76 -0
- package/package.json +11 -2
- package/src/composables/index.ts +9 -0
- package/src/composables/useTripMap.ts +124 -0
- package/src/edit/TripMapEdit.vue +31 -0
- package/src/edit/index.ts +2 -0
- package/src/index.ts +3 -0
- package/src/registry.ts +7 -0
- package/src/render/index.ts +4 -0
- package/src/render/tripmap/TripMapDefault.vue +189 -0
- package/src/render/tripmap/index.ts +8 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* TripMap Default Render Component
|
|
4
|
+
*
|
|
5
|
+
* SSR-safe facade → lazy MapLibre island. The server (and any no-JS
|
|
6
|
+
* surface, incl. the sanitized article-review embed) sees a static strip
|
|
7
|
+
* of photo thumbnails + waypoint list + a placeholder box; when the block
|
|
8
|
+
* scrolls near the viewport we fetch the geo nodes and dynamically import
|
|
9
|
+
* @blogmcp/tripmap-core (the ONLY path to maplibre-gl — see useTripMap's
|
|
10
|
+
* module docblock for why this must never become a static import). If the
|
|
11
|
+
* import or fetch fails, the facade simply stays.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
|
15
|
+
import { tripMapStyles } from '@blogmcp/mdx-schema/styles'
|
|
16
|
+
import type { TripMapProps } from '@blogmcp/mdx-schema'
|
|
17
|
+
import { tripMapFormatTime, useTripMap, type TripMapGeoResponse } from '../../composables/useTripMap'
|
|
18
|
+
|
|
19
|
+
// withDefaults is load-bearing for the boolean props: Vue boolean-casts an
|
|
20
|
+
// ABSENT `filmstrip?: boolean` prop to false, which would silently flip all
|
|
21
|
+
// the default-true toggles off for every author who doesn't spell them out.
|
|
22
|
+
const props = withDefaults(defineProps<TripMapProps>(), {
|
|
23
|
+
line: true,
|
|
24
|
+
filmstrip: true,
|
|
25
|
+
playback: true,
|
|
26
|
+
cluster: true,
|
|
27
|
+
theme: 'auto',
|
|
28
|
+
detail: 'popup',
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const { slugList, handNodes, facadeThumbs, facadeStops, heightStyle, geoUrl, isConfigOnly, labels, texts } =
|
|
32
|
+
useTripMap(props)
|
|
33
|
+
|
|
34
|
+
const rootEl = ref<HTMLElement | null>(null)
|
|
35
|
+
const mapEl = ref<HTMLElement | null>(null)
|
|
36
|
+
// detail="panel": the engine mounts the photo panel here, below the sized map box.
|
|
37
|
+
const detailEl = ref<HTMLElement | null>(null)
|
|
38
|
+
const status = ref<'facade' | 'loading' | 'live' | 'empty'>('facade')
|
|
39
|
+
const skippedCount = ref(0)
|
|
40
|
+
|
|
41
|
+
let destroyMap: (() => void) | null = null
|
|
42
|
+
let observer: IntersectionObserver | null = null
|
|
43
|
+
let started = false
|
|
44
|
+
|
|
45
|
+
async function fetchGeoNodes(): Promise<TripMapGeoResponse> {
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(geoUrl.value, { headers: { accept: 'application/json' } })
|
|
48
|
+
if (!res.ok) throw new Error(`media/geo ${res.status}`)
|
|
49
|
+
return (await res.json()) as TripMapGeoResponse
|
|
50
|
+
} catch (err) {
|
|
51
|
+
// Config-only call (hand-made nodes, nothing selected): the map still has
|
|
52
|
+
// its nodes — run on the core default style rather than staying a facade.
|
|
53
|
+
// With a selection the nodes ARE the response, so the failure propagates
|
|
54
|
+
// and the static facade stays (designed degradation).
|
|
55
|
+
if (isConfigOnly.value) return { nodes: [] }
|
|
56
|
+
throw err
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function start(): Promise<void> {
|
|
61
|
+
if (started) return
|
|
62
|
+
started = true
|
|
63
|
+
status.value = 'loading'
|
|
64
|
+
try {
|
|
65
|
+
const [core, geo] = await Promise.all([import('@blogmcp/tripmap-core'), fetchGeoNodes()])
|
|
66
|
+
const mediaNodes = (geo.nodes ?? [])
|
|
67
|
+
.filter((n) => typeof n.latitude === 'number' && typeof n.longitude === 'number')
|
|
68
|
+
.map((n) => ({
|
|
69
|
+
id: n.slug,
|
|
70
|
+
lat: n.latitude,
|
|
71
|
+
lng: n.longitude,
|
|
72
|
+
capturedAt: n.capturedAt ?? undefined,
|
|
73
|
+
thumbnailUrl: n.thumbnailUrl ?? undefined,
|
|
74
|
+
imageUrl: n.url ?? undefined,
|
|
75
|
+
largeImageUrl: n.largeUrl ?? undefined,
|
|
76
|
+
altText: n.altText ?? undefined,
|
|
77
|
+
title: n.placeName ?? undefined,
|
|
78
|
+
}))
|
|
79
|
+
const nodes = core.buildTripNodes(mediaNodes, handNodes.value)
|
|
80
|
+
skippedCount.value = geo.skipped ?? 0
|
|
81
|
+
if (nodes.length === 0 || !mapEl.value) {
|
|
82
|
+
status.value = 'empty'
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
const map = new core.TripMap(mapEl.value, {
|
|
86
|
+
styleUrl: props.styleUrl || geo.config?.styleUrl || '',
|
|
87
|
+
darkStyleUrl: geo.config?.darkStyleUrl,
|
|
88
|
+
theme: props.theme,
|
|
89
|
+
cluster: props.cluster,
|
|
90
|
+
showLine: props.line,
|
|
91
|
+
showFilmstrip: props.filmstrip,
|
|
92
|
+
showPlayback: props.playback,
|
|
93
|
+
title: props.title,
|
|
94
|
+
detail: props.detail,
|
|
95
|
+
detailHost: props.detail === 'panel' ? (detailEl.value ?? undefined) : undefined,
|
|
96
|
+
extraAttribution: geo.config?.attribution,
|
|
97
|
+
labels: labels.value,
|
|
98
|
+
initialView: initialViewFromProps(),
|
|
99
|
+
events: {},
|
|
100
|
+
})
|
|
101
|
+
map.setNodes(nodes)
|
|
102
|
+
await map.init()
|
|
103
|
+
destroyMap = () => map.destroy()
|
|
104
|
+
status.value = 'live'
|
|
105
|
+
} catch {
|
|
106
|
+
// Core unavailable (review-app iframe, offline) or fetch failed —
|
|
107
|
+
// the static facade stays, which is the designed degradation.
|
|
108
|
+
status.value = started && status.value === 'loading' ? 'facade' : status.value
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function initialViewFromProps(): { center?: [number, number]; zoom?: number } | undefined {
|
|
113
|
+
if (!props.center) return undefined
|
|
114
|
+
const [lat, lng] = props.center.split(',').map((v) => Number.parseFloat(v.trim()))
|
|
115
|
+
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return undefined
|
|
116
|
+
return { center: [lng, lat], zoom: props.zoom }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
onMounted(() => {
|
|
120
|
+
const el = rootEl.value
|
|
121
|
+
if (!el) return
|
|
122
|
+
if (typeof IntersectionObserver === 'undefined') {
|
|
123
|
+
void start()
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
observer = new IntersectionObserver(
|
|
127
|
+
(entries) => {
|
|
128
|
+
if (entries.some((e) => e.isIntersecting)) {
|
|
129
|
+
observer?.disconnect()
|
|
130
|
+
observer = null
|
|
131
|
+
void start()
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
{ rootMargin: '300px' },
|
|
135
|
+
)
|
|
136
|
+
observer.observe(el)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
onBeforeUnmount(() => {
|
|
140
|
+
observer?.disconnect()
|
|
141
|
+
destroyMap?.()
|
|
142
|
+
})
|
|
143
|
+
</script>
|
|
144
|
+
|
|
145
|
+
<template>
|
|
146
|
+
<div ref="rootEl" :class="tripMapStyles.container" data-mdx-component="TripMap" :aria-label="title">
|
|
147
|
+
<p v-if="title" :class="tripMapStyles.head">{{ title }}</p>
|
|
148
|
+
|
|
149
|
+
<!--
|
|
150
|
+
Facade thumb strip — server-rendered, with alt text when the host
|
|
151
|
+
provided the article media (crawlers read THIS, never the canvas);
|
|
152
|
+
replaced by the live filmstrip once the engine mounts.
|
|
153
|
+
-->
|
|
154
|
+
<ol v-if="facadeThumbs.length && status !== 'live'" :class="tripMapStyles.strip">
|
|
155
|
+
<li v-for="thumb in facadeThumbs" :key="thumb.slug" :class="tripMapStyles.stripItem">
|
|
156
|
+
<img :src="thumb.src" :alt="thumb.alt" width="80" height="80" loading="lazy" decoding="async" :class="tripMapStyles.stripImg" />
|
|
157
|
+
</li>
|
|
158
|
+
</ol>
|
|
159
|
+
|
|
160
|
+
<!--
|
|
161
|
+
Sized wrapper: the engine host is a static absolutely-positioned child
|
|
162
|
+
that Vue never re-patches (reactive bindings on the engine's container
|
|
163
|
+
would clobber the classes/children the engine adds); the facade box is
|
|
164
|
+
an overlay that simply unmounts when the map goes live.
|
|
165
|
+
-->
|
|
166
|
+
<div :class="tripMapStyles.mapWrap" :style="heightStyle">
|
|
167
|
+
<div ref="mapEl" class="absolute inset-0"></div>
|
|
168
|
+
<div v-if="status !== 'live'" :class="[tripMapStyles.mapOverlay]">
|
|
169
|
+
<div :class="tripMapStyles.mapBox">
|
|
170
|
+
<span :class="tripMapStyles.mapHint">
|
|
171
|
+
{{ status === 'empty' ? texts.empty : texts.hint }}
|
|
172
|
+
</span>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
<!-- detail="panel": engine-owned photo panel host (static, never re-patched). -->
|
|
177
|
+
<div v-if="props.detail === 'panel'" ref="detailEl" :class="tripMapStyles.detailHost"></div>
|
|
178
|
+
|
|
179
|
+
<!-- Named stops (place-labelled photos + hand-made nodes) for the static facade. -->
|
|
180
|
+
<ol v-if="facadeStops.length && status !== 'live'" :class="tripMapStyles.stopList">
|
|
181
|
+
<li v-for="stop in facadeStops" :key="stop.key">
|
|
182
|
+
<span v-if="stop.capturedAt" :class="tripMapStyles.stopTime">{{ tripMapFormatTime(stop.capturedAt) }}</span>
|
|
183
|
+
<span>{{ stop.title }}</span>
|
|
184
|
+
</li>
|
|
185
|
+
</ol>
|
|
186
|
+
|
|
187
|
+
<p v-if="status !== 'live'" :class="tripMapStyles.note">{{ texts.unavailable }}</p>
|
|
188
|
+
</div>
|
|
189
|
+
</template>
|