template-bundle: the starter's first screen is now a real map

A new writer's first copy used to draw a coloured box. template-map-a.vue now
draws the points in data/places.geojson on a basemap with one translated title,
through the library's own pieces (useMapInstance, resolveMapProvider, labelFont,
emissivePaint). It follows the page's engine, locale (right-to-left included) and
token, and with no token it runs on keyless MapLibre, so it works with no account.

template-map-b.vue stays as the second-screen example, so the README, the tutorial
and the plugin-designer skill (rename a, delete b) stay true. The README gains the
map's pieces table and the dx-maps paths.

Library change: library/i18n/locales/<11>/template.json (key template.title) is
eagerly globbed into every bundle, so consumers need rebuilding (rule 5B).
This commit is contained in:
Doxa Maps 2026-09-26 02:56:05 +00:00
parent 031b9b38bf
commit 22b92ae043
16 changed files with 151 additions and 52 deletions

View file

@ -0,0 +1,3 @@
{
"title": "خريطتي الأولى"
}

View file

@ -0,0 +1,3 @@
{
"title": "Meine erste Karte"
}

View file

@ -0,0 +1,3 @@
{
"title": "My first map"
}

View file

@ -0,0 +1,3 @@
{
"title": "Mi primer mapa"
}

View file

@ -0,0 +1,3 @@
{
"title": "Ma première carte"
}

View file

@ -0,0 +1,3 @@
{
"title": "मेरा पहला नक्शा"
}

View file

@ -0,0 +1,3 @@
{
"title": "La mia prima mappa"
}

View file

@ -0,0 +1,3 @@
{
"title": "Meu primeiro mapa"
}

View file

@ -0,0 +1,3 @@
{
"title": "Prima mea hartă"
}

View file

@ -0,0 +1,3 @@
{
"title": "Моя первая карта"
}

View file

@ -0,0 +1,3 @@
{
"title": "我的第一张地图"
}

View file

@ -3,8 +3,14 @@
**This folder is the STARTER. Copy it to make a new plugin. That's the whole idea.**
Your starting point. **Copy this folder, then build your map inside the copy** — don't start from
blank. It's a tiny working bundle (two trivial profiles that render a colored box, no Mapbox, no
data) plus a `src/` sandbox laid out for your real code.
blank. It's a tiny working bundle plus a `src/` sandbox laid out for your real code:
- **`template-map-a.vue` is a real map.** It draws the points in `data/places.geojson` on a basemap,
shows one translated title, and follows the page's engine, language (including right-to-left) and
Mapbox token. **It needs no account**: with no token it runs on the free MapLibre engine. Swap in
your own points and it is your map.
- **`template-map-b.vue`** is a second, trivial screen (a coloured box). It only shows that one
bundle can hold several screens. Delete it if you want one.
## In plain English (read this first)
@ -24,7 +30,7 @@ If you've never touched this repo, here are the only 4 words you need:
cp -r plugin-designs/template-bundle plugin-designs/my-plugin
# 2. build (the ONE command)
bun run build # → public/doxa-maps/my-plugin/my-plugin.js (your paste-anywhere plugin)
bun run build # → public/dx-maps/my-plugin/my-plugin.js (your paste-anywhere plugin)
```
Then rename the tag + profile strings (the "exactly four things" list below) so your copy has its
@ -70,8 +76,9 @@ needs it — an expert move, not a first move. To see everything `library/` offe
template-bundle/
├── index.js ← bundle entry: registers <template-bundle>, globs ./*.vue
├── index.html ← dev staging page (the build needs it to find the bundle)
├── template-map-a.vue ← profile "template-map-a" ← FLAT at the bundle root
├── template-map-b.vue ← profile "template-map-b" ← FLAT at the bundle root
├── template-map-a.vue ← profile "template-map-a": THE MAP ← FLAT at the bundle root
├── template-map-b.vue ← profile "template-map-b": a box ← FLAT at the bundle root
├── data/places.geojson ← the map's points (imported by template-map-a.vue, ships inside the .js)
└── src/ ← YOUR SANDBOX (each subfolder has its own README)
├── api/ colors/ components/ composables/ utils/
```
@ -81,7 +88,21 @@ template-bundle/
(`import.meta.glob('./*.vue')`). A `.vue` inside a `profiles/` subfolder would NOT be found. Keep
your profile screens flat; the small pieces they're built from live in `src/`.
Everything builds into **one** file: `public/doxa-maps/template-bundle/template-bundle.js`.
Everything builds into **one** file: `public/dx-maps/template-bundle/template-bundle.js`.
## The map's pieces (template-map-a.vue)
| Piece | Where it comes from | To change it |
|---|---|---|
| the points | `data/places.geojson` (a static import, so no extra request on the host) | replace the file with your own GeoJSON points |
| the pin colour | `src/colors/colors.json` → `pins.selected` | edit the hex |
| the engine (Mapbox or MapLibre) | the page: `profile-config` `provider`, `?provider=`, or `window.MAP_PROVIDER`; no token → MapLibre | nothing: leave it to the page |
| the language and right-to-left | the page's locale, given by the library's ProfileLoader | nothing: leave it to the page |
| the title | `t('template.title')` from `library/i18n/locales/<lc>/template.json` | see below |
**Your own text.** Translations live in `library/` (every locale in one place), so a new key there
is a library change: ask the build owner to land it. Until then, a plain English string in your
`.vue` is fine.
## The two ways you import
@ -93,7 +114,7 @@ There's no alias for your sandbox — your code is always relative. That boundar
## Embed it
```html
<script src="/doxa-maps/template-bundle/template-bundle.js"></script>
<script src="/dx-maps/template-bundle/template-bundle.js"></script>
<template-bundle profile-config='{"profile":"template-map-a"}'></template-bundle>
<template-bundle profile-config='{"profile":"template-map-b"}'></template-bundle>
```

View file

@ -0,0 +1,11 @@
{
"type": "FeatureCollection",
"features": [
{ "type": "Feature", "properties": { "name": "Nairobi" }, "geometry": { "type": "Point", "coordinates": [36.82, -1.29] } },
{ "type": "Feature", "properties": { "name": "Cairo" }, "geometry": { "type": "Point", "coordinates": [31.24, 30.04] } },
{ "type": "Feature", "properties": { "name": "Delhi" }, "geometry": { "type": "Point", "coordinates": [77.21, 28.61] } },
{ "type": "Feature", "properties": { "name": "Jakarta" }, "geometry": { "type": "Point", "coordinates": [106.85, -6.21] } },
{ "type": "Feature", "properties": { "name": "Lima" }, "geometry": { "type": "Point", "coordinates": [-77.04, -12.05] } },
{ "type": "Feature", "properties": { "name": "Istanbul" }, "geometry": { "type": "Point", "coordinates": [28.98, 41.01] } }
]
}

View file

@ -12,7 +12,7 @@
* profile, all compiled into the single template-bundle.js.
*
* Usage from a host page:
* <script src="/doxa-maps/template-bundle/template-bundle.js"></script>
* <script src="/dx-maps/template-bundle/template-bundle.js"></script>
* <template-bundle profile-config='{"profile":"template-map-a"}'></template-bundle>
* <template-bundle profile-config='{"profile":"template-map-b"}'></template-bundle>
*

View file

@ -1,65 +1,99 @@
<script setup>
/**
* template-map-a.vue — example profile "A"
* template-map-a.vue — example profile "A": a minimal REAL map. Copy this.
*
* @description Minimal example profile A — a colored placeholder, no Mapbox or data.
* @description Minimal working map: one data layer from data/, one translated string, both engines.
* @profile template-map-a
* @bundle template-bundle
* @element template-bundle
*
* This file sits DIRECTLY in the bundle folder (plugin-designs/template-bundle/),
* NOT in a nested profiles/ subfolder. Its filename without `.vue` —
* `template-map-a` — is the profile name you request from the host page:
* This file sits DIRECTLY in the bundle folder, NOT in a profiles/ subfolder. Its filename without
* `.vue` is the profile name a host requests:
*
* <template-bundle profile-config='{"profile":"template-map-a"}'></template-bundle>
*
* A profile is a normal Vue component. It can read the host's profile-config via
* inject('profileConfig') — the same channel the real map profiles use — without
* any prop-drilling across the web-component boundary.
*
* It also shows the sandbox import boundary: this profile pulls its accent color
* from src/colors/colors.json via a RELATIVE path (./src/...). Your own sandbox
* code is always a relative import; the @map/... alias is only for the shared
* library. Edit the hex values in that JSON and rebuild to recolor — no code.
* WHAT A NEW MAP GETS BY COPYING THIS (every piece is the library's, so it behaves like the others):
* - the ENGINE, the LOCALE (+ right-to-left) and the Mapbox TOKEN are the page's universal props
* (documentation/reference-universal-props.md). useMapInstance reads the engine; ProfileLoader
* gives the locale, `dir`, and the token (inject('mapboxToken')). Nothing is hard-coded here.
* - WITH NO TOKEN it runs on keyless MapLibre, so a first copy works with no account at all.
* - one DATA layer from this bundle's own data/ folder (a static import ships it inside the bundle,
* so it loads on any host with no extra request);
* - one TRANSLATED string: t('template.title'), from library/i18n/locales/<lc>/template.json;
* - label fonts and emissive paint that are right on either engine (labelFont, emissivePaint).
* - a pin colour from THIS bundle's src/colors/colors.json: your own code is a RELATIVE import;
* @map/... is only for the shared library. Edit the hex and rebuild to recolour.
* Replace data/places.geojson with your own points, rename the title key, and you have your map.
*/
import { inject, computed } from 'vue'
import { inject, ref, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { resolveMapProvider, emissivePaint } from '@map/composables/useMapProvider.js'
import { labelFont } from '@map/maps/label-font.js'
import colors from './src/colors/colors.json'
import places from './data/places.geojson?raw'
const config = inject('profileConfig', null)
const note = computed(() => config?.value?.note || '(no note passed in profile-config)')
const accent = colors.pins.selected
const { t } = useI18n()
const profileConfig = inject('profileConfig', null)
const mapboxToken = inject('mapboxToken', ref(''))
// The page's engine choice (profile-config provider → ?provider= → window.MAP_PROVIDER). With no
// token, Mapbox cannot draw at all, so a keyless page gets MapLibre. (Once the library's AUTO default
// lands, resolveMapProvider does this itself and this line can go.)
const asked = resolveMapProvider(profileConfig)
const provider = asked === 'mapbox' && !mapboxToken.value ? 'maplibre' : asked
const mapEl = ref(null)
const error = ref('')
const { map, initializeMap, destroy } = useMapInstance({
containerRef: mapEl,
accessToken: mapboxToken.value,
provider,
// Under MapLibre, useMapInstance swaps this mapbox:// style for its free (OpenFreeMap) basemap.
style: 'mapbox://styles/mapbox/light-v11',
center: [40, 15],
zoom: 1.4,
})
function addPlaces(m) {
m.addSource('places', { type: 'geojson', data: JSON.parse(places) })
m.addLayer({
id: 'places-dots', type: 'circle', source: 'places',
paint: { 'circle-radius': 7, 'circle-color': colors.pins.selected, 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2, ...emissivePaint(m) },
})
m.addLayer({
id: 'places-labels', type: 'symbol', source: 'places',
layout: { 'text-field': ['get', 'name'], 'text-size': 12, 'text-offset': [0, 1.2], 'text-anchor': 'top', ...labelFont(m, { weight: 'medium' }) },
paint: { 'text-color': '#3b463d', 'text-halo-color': '#ffffff', 'text-halo-width': 1.5 },
})
}
onMounted(async () => {
try { await initializeMap() } catch (e) { error.value = String(e?.message || e); return }
const m = map.value
if (!m) { error.value = 'map engine not loaded'; return }
// add the data once the style is ready (it may already be, so do not wait for an event that passed)
if (m.isStyleLoaded()) addPlaces(m)
else m.once('load', () => addPlaces(m))
})
onBeforeUnmount(() => destroy())
</script>
<template>
<div class="tpl tpl--a">
<h2>Profile A</h2>
<p>Served from <code>template-map-a.vue</code> in the <code>template-bundle</code> bundle.</p>
<p class="note">note: {{ note }}</p>
<p class="swatch">
accent from <code>src/colors/colors.json</code>:
<span class="dot" :style="{ background: accent }"></span> {{ accent }}
</p>
<div class="tpl-map">
<div ref="mapEl" class="tpl-map__canvas"></div>
<div class="tpl-map__title">{{ t('template.title') }}</div>
<div v-if="error" class="tpl-map__error">{{ error }}</div>
</div>
</template>
<style scoped>
.tpl {
width: 100%; height: 100%;
display: flex; flex-direction: column; gap: 6px;
align-items: center; justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
text-align: center;
.tpl-map { position: relative; width: 100%; height: 100%; min-height: 360px; }
.tpl-map__canvas { position: absolute; inset: 0; }
.tpl-map__title {
position: absolute; top: 12px; inset-inline-start: 12px; z-index: 2;
background: #ffffff; color: #3b463d; border: 1px solid #d1d4cd; border-radius: 8px;
padding: 6px 10px; font: 600 14px/1.3 -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
}
/* MATERIAL LIGHT (2026-09-21). Was background #0b1f2a / color #7dd3fc.
★ THIS FILE IS THE COPY-ME STARTER, so a palette violation here PROPAGATES: the skill's recipe
is "copy template-bundle and rename it", which means every future plugin would inherit a dark
surface the house style forbids. Fixing the template is worth more than fixing one leaf.
The two profiles still differ visibly (a uses the accent tint, b a cooler border) — that
distinction is the point of shipping two, and it survives on the light palette. */
.tpl--a { background: #f3f3f1; color: #3b463d; border: 1px solid #92b195; }
.tpl h2 { font-size: 22px; font-weight: 700; }
.tpl code { background: rgba(59, 70, 61, 0.08); padding: 1px 6px; border-radius: 4px; }
.tpl .note { font-size: 12px; opacity: 0.7; }
.tpl .swatch { font-size: 12px; opacity: 0.85; display: flex; align-items: center; gap: 6px; }
.tpl .dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; }
.tpl-map__error { position: absolute; bottom: 12px; inset-inline-start: 12px; z-index: 2; color: #b3412f; background: #fff; padding: 6px 10px; border-radius: 6px; }
</style>

View file

@ -9,7 +9,7 @@
*
* A SECOND profile living in the SAME bundle folder, alongside
* template-map-a.vue (both flat, no profiles/ subfolder). Both compile into the
* single output file app/doxa-maps/template-bundle/template-bundle.js — one
* single output file public/dx-maps/template-bundle/template-bundle.js — one
* bundle, two profiles.
* The host chooses which one to show with the `profile` field:
*