From 81bf70d5f374e5e441d58859773d615c6188e6b8 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Wed, 1 Jul 2026 17:47:38 +0000 Subject: [PATCH] =?UTF-8?q?Map=E2=86=94card=20interaction:=20link=20pins?= =?UTF-8?q?=20to=20cards=20+=20numbered=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MapLink component links a card to a Map marker by shared id (+ group): hover/click a pin rings its card and scrolls it into view; hover a card opens the pin's popup and pans to it. - Map: markers gain optional id; new group + numbered props (1..N pins). - map-link.ts: tiny module-level focus pub/sub (no provider wiring), keyed by group so independent boards don't cross-talk; each side ignores its own source to avoid feedback loops. - CSS ring for .tc-maplink(:hover/--active); tests (bus isolation, resolver registration, active-class toggle via react-dom/client, Map link props); component.md recipe. --- .../client/renderers/component-resolver.tsx | 3 + .../src/client/renderers/map-link-card.tsx | 47 ++++++++++ .../viewer/src/client/renderers/map-link.ts | 40 +++++++++ .../viewer/src/client/renderers/map-node.tsx | 64 +++++++++++-- packages/viewer/src/client/style.css | 8 ++ packages/viewer/test/map-link.test.ts | 89 +++++++++++++++++++ plugin/skills/diagram-recipes/component.md | 23 +++++ 7 files changed, 266 insertions(+), 8 deletions(-) create mode 100644 packages/viewer/src/client/renderers/map-link-card.tsx create mode 100644 packages/viewer/src/client/renderers/map-link.ts create mode 100644 packages/viewer/test/map-link.test.ts diff --git a/packages/viewer/src/client/renderers/component-resolver.tsx b/packages/viewer/src/client/renderers/component-resolver.tsx index f217289..15e76f1 100644 --- a/packages/viewer/src/client/renderers/component-resolver.tsx +++ b/packages/viewer/src/client/renderers/component-resolver.tsx @@ -10,6 +10,7 @@ import { VegaLite } from "./vega-node.js"; import { Map } from "./map-node.js"; import { Checklist } from "./checklist.js"; import { Video } from "./video.js"; +import { MapLink } from "./map-link-card.js"; export interface ComponentNode { type: string; @@ -43,6 +44,8 @@ const REGISTRY: Record> = { VegaLite, // interactive map (Leaflet + OSM tiles loaded on demand inside the node) Map, + // links a card to a Map marker of the same id (hover/select highlights both ways) + MapLink, // interactive checklist — mutable from BOTH the agent (setChecked patch) and the human (interact) Checklist, // video thumbnail card (YouTube-style) — links out, or click-to-play inline (youtube-nocookie) diff --git a/packages/viewer/src/client/renderers/map-link-card.tsx b/packages/viewer/src/client/renderers/map-link-card.tsx new file mode 100644 index 0000000..ab9b44a --- /dev/null +++ b/packages/viewer/src/client/renderers/map-link-card.tsx @@ -0,0 +1,47 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { emitFocus, subscribeFocus } from "./map-link.js"; + +/** + * `MapLink` — wraps a card (or any node) and links it to a `Map` marker sharing the same `id`. + * Hovering/focusing the card highlights + opens its map pin; when the pin is hovered/clicked the + * card rings and scrolls into view. Purely additive: a `MapLink` with an id that no marker uses + * just renders its children with a hover ring. Give the matching `Map` marker the same `id` (and, + * if you scope by board, the same `group`). + * + * Shape: `{ "type":"MapLink", "props":{ "id":"day-1", "group":"trip" }, "children":[ ...card... ] }` + */ +export interface MapLinkProps { + id: string; + group?: string; + children?: ReactNode; +} + +export function MapLink({ id, group = "default", children }: MapLinkProps) { + const ref = useRef(null); + const [active, setActive] = useState(false); + + useEffect(() => { + return subscribeFocus(group, (e) => { + if (e.source !== "map") return; // only react to the map side + const on = e.id === id; + setActive(on); + if (on && ref.current) { + ref.current.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + }); + }, [group, id]); + + return ( +
emitFocus(group, { id, source: "card" })} + onMouseLeave={() => emitFocus(group, { id: null, source: "card" })} + onFocus={() => emitFocus(group, { id, source: "card" })} + > + {children} +
+ ); +} diff --git a/packages/viewer/src/client/renderers/map-link.ts b/packages/viewer/src/client/renderers/map-link.ts new file mode 100644 index 0000000..e6d92bf --- /dev/null +++ b/packages/viewer/src/client/renderers/map-link.ts @@ -0,0 +1,40 @@ +/** + * Tiny module-level pub/sub that links `Map` markers to sibling `MapLink` cards by a shared `id`, + * so hovering/selecting one highlights the other. It lives at module scope (not a React context) so + * the two components communicate without threading a provider through the generic component + * resolver. Events are keyed by `group` (default "default") so two independent boards on one screen + * don't cross-talk. + * + * Flow: a marker mouseover/click emits `{id, source:"map"}` → the matching `MapLink` card rings + + * scrolls into view. A card mouseenter emits `{id, source:"card"}` → the matching marker opens its + * popup and the map pans to it. Each side ignores its own `source` to avoid feedback loops. + */ +export type FocusSource = "map" | "card"; +export interface FocusEvent { + /** The linked id being focused, or null to clear focus. */ + id: string | null; + /** Which side originated the event (listeners ignore their own source). */ + source: FocusSource; +} +type Listener = (e: FocusEvent) => void; + +const groups = new Map>(); + +export function subscribeFocus(group: string, fn: Listener): () => void { + let set = groups.get(group); + if (!set) { + set = new Set(); + groups.set(group, set); + } + set.add(fn); + return () => { + set!.delete(fn); + if (set!.size === 0) groups.delete(group); + }; +} + +export function emitFocus(group: string, event: FocusEvent): void { + const set = groups.get(group); + if (!set) return; + for (const fn of set) fn(event); +} diff --git a/packages/viewer/src/client/renderers/map-node.tsx b/packages/viewer/src/client/renderers/map-node.tsx index 669b42d..6fa583e 100644 --- a/packages/viewer/src/client/renderers/map-node.tsx +++ b/packages/viewer/src/client/renderers/map-node.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from "react"; import { injectStyle } from "./inject-style.js"; +import { emitFocus, subscribeFocus } from "./map-link.js"; /** * An interactive Leaflet + OpenStreetMap map usable inside a Mantine `component` tree: @@ -24,6 +25,9 @@ export interface MapMarker extends LatLng { label?: string; /** A named color (blue/red/green/amber/grey/purple) or any CSS color; default blue. */ color?: string; + /** Optional link id: a sibling `MapLink` card with the same id highlights when this pin is + * hovered/clicked, and this pin's popup opens when that card is hovered. */ + id?: string; } export interface MapRoute { @@ -47,6 +51,10 @@ export interface MapProps { routes?: MapRoute[]; /** Tile provider. Only "osm" (default) is wired; no API key needed. */ tileLayer?: string; + /** Link group — pairs this map with `MapLink` cards of the same group (default "default"). */ + group?: string; + /** Number the pins 1..N in marker order (handy for an ordered route/itinerary). */ + numbered?: boolean; } /** Named marker/route colors → hex. Unknown names fall through to the raw value (any CSS color). */ @@ -67,13 +75,19 @@ const resolveColor = (c: string | undefined, fallback = "#2b6cb0"): string => * A teardrop pin SVG (data-URI) tinted by `color`. Inlined so markers need no remote PNG/sprite * (the default Leaflet marker image relies on a bundled asset path that esbuild wouldn't ship). */ -function pinIconHtml(color: string): string { +function pinIconHtml(color: string, num?: number): string { const fill = resolveColor(color); + const badge = + num != null + ? `` + + `${num}` + : ``; return ( `` ); } @@ -81,12 +95,22 @@ function pinIconHtml(color: string): string { const OSM_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"; const OSM_ATTR = '© OpenStreetMap contributors'; -export function Map({ h = 320, center, zoom = 12, markers = [], routes = [], tileLayer = "osm" }: MapProps) { +export function Map({ + h = 320, + center, + zoom = 12, + markers = [], + routes = [], + tileLayer = "osm", + group = "default", + numbered = false, +}: MapProps) { const ref = useRef(null); useEffect(() => { if (!ref.current) return; let map: import("leaflet").Map | undefined; let ro: ResizeObserver | undefined; + let unsub: (() => void) | undefined; let cancelled = false; (async () => { @@ -111,17 +135,40 @@ export function Map({ h = 320, center, zoom = 12, markers = [], routes = [], til // Only OSM is wired today; other providers can be added here behind a key. No key needed for OSM. L.tileLayer(OSM_URL, { maxZoom: 19, attribution: OSM_ATTR }).addTo(map); - for (const m of markers) { + // id → leaflet marker, so a hovered `MapLink` card can open the matching pin's popup. + // (globalThis.Map — the built-in Map is shadowed by this module's `Map` component export.) + const byId = new globalThis.Map(); + markers.forEach((m, i) => { const icon = L.divIcon({ className: "tc-map-pin", - html: pinIconHtml(m.color ?? "blue"), + html: pinIconHtml(m.color ?? "blue", numbered ? i + 1 : undefined), iconSize: [26, 38], iconAnchor: [13, 38], popupAnchor: [0, -34], }); - const marker = L.marker([m.lat, m.lng], { icon, title: m.label }).addTo(map); + const marker = L.marker([m.lat, m.lng], { icon, title: m.label }).addTo(map!); if (m.label) marker.bindPopup(m.label).bindTooltip(m.label, { direction: "top", offset: [0, -32] }); - } + if (m.id) { + const id = m.id; + byId.set(id, marker); + // Hover/click the pin → focus the linked card (source "map"; cards ignore "card"). + marker.on("mouseover", () => emitFocus(group, { id, source: "map" })); + marker.on("click", () => emitFocus(group, { id, source: "map" })); + marker.on("mouseout", () => emitFocus(group, { id: null, source: "map" })); + } + }); + + // Card → map: when a `MapLink` card is hovered, open its pin's popup and pan to it. + unsub = subscribeFocus(group, (e) => { + if (e.source !== "card" || !map) return; + if (e.id && byId.has(e.id)) { + const mk = byId.get(e.id)!; + mk.openPopup(); + map.panTo(mk.getLatLng(), { animate: true }); + } else { + for (const mk of byId.values()) mk.closePopup(); + } + }); for (const r of routes) { // Explicit `path` wins; otherwise draw the straight from→to segment. @@ -155,11 +202,12 @@ export function Map({ h = 320, center, zoom = 12, markers = [], routes = [], til return () => { cancelled = true; + unsub?.(); ro?.disconnect(); map?.remove(); }; // tileLayer is referenced for completeness; only "osm" is wired so it never changes behavior. - }, [h, center, zoom, markers, routes, tileLayer]); + }, [h, center, zoom, markers, routes, tileLayer, group, numbered]); return (
:first-child { margin-top: 0; } diff --git a/packages/viewer/test/map-link.test.ts b/packages/viewer/test/map-link.test.ts new file mode 100644 index 0000000..5706716 --- /dev/null +++ b/packages/viewer/test/map-link.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment happy-dom +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +import { describe, expect, it } from "vitest"; +import { act, createElement, type ReactElement } from "react"; +import { createRoot } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MantineProvider } from "@mantine/core"; +import { resolve, componentNames } from "../src/client/renderers/component-resolver.js"; +import { MapLink } from "../src/client/renderers/map-link-card.js"; +import { emitFocus, subscribeFocus } from "../src/client/renderers/map-link.js"; + +describe("map-link focus bus", () => { + it("delivers to subscribers of the same group only, and stops after unsubscribe", () => { + const g1: unknown[] = []; + const g2: unknown[] = []; + const off1 = subscribeFocus("g1", (e) => g1.push(e)); + const off2 = subscribeFocus("g2", (e) => g2.push(e)); + emitFocus("g1", { id: "x", source: "map" }); + expect(g1).toEqual([{ id: "x", source: "map" }]); + expect(g2).toEqual([]); // group isolation — g2 not notified + off1(); + emitFocus("g1", { id: "y", source: "map" }); + expect(g1.length).toBe(1); // unsubscribed → no further delivery + off2(); + }); +}); + +describe("MapLink component", () => { + it("is registered in the resolver and renders its children with the link id", () => { + expect(componentNames()).toContain("MapLink"); + const html = renderToStaticMarkup( + resolve({ type: "MapLink", props: { id: "day-1" }, children: "Aoshima" }) as ReactElement, + ); + expect(html).toContain('data-map-id="day-1"'); + expect(html).toContain("tc-maplink"); + expect(html).toContain("Aoshima"); + }); + + it("rings (active class) only when its linked marker is focused from the MAP side", async () => { + // happy-dom has no scrollIntoView; stub it so the effect's scroll call doesn't throw. + (Element.prototype as unknown as { scrollIntoView: () => void }).scrollIntoView = () => {}; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render(createElement(MapLink, { id: "day-2", group: "trip" }, "card body")); + }); + const el = () => container.querySelector(".tc-maplink") as HTMLElement; + expect(el().className).not.toContain("tc-maplink--active"); + + // a card-source event must be IGNORED (no feedback loop) + await act(async () => emitFocus("trip", { id: "day-2", source: "card" })); + expect(el().className).not.toContain("tc-maplink--active"); + + // the matching map-source event activates it + await act(async () => emitFocus("trip", { id: "day-2", source: "map" })); + expect(el().className).toContain("tc-maplink--active"); + + // focusing a different id clears it + await act(async () => emitFocus("trip", { id: "other", source: "map" })); + expect(el().className).not.toContain("tc-maplink--active"); + + await act(async () => root.unmount()); + }); +}); + +describe("Map link props", () => { + it("accepts marker ids, numbered pins, and a group without throwing (SSR)", () => { + const el = resolve({ + type: "Map", + props: { + h: 300, + numbered: true, + group: "trip", + markers: [ + { lat: 31.36, lng: 131.33, label: "Day 1", color: "#2dd4bf", id: "day-1" }, + { lat: 31.79, lng: 131.47, label: "Day 3", color: "#06b6d4", id: "day-3" }, + ], + }, + }) as ReactElement<{ markers: unknown[]; numbered: boolean; group: string }>; + expect(el.type).not.toBe("pre"); // registered, not the unknown-component marker + expect(Array.isArray(el.props.markers)).toBe(true); + expect(el.props.numbered).toBe(true); + expect(el.props.group).toBe("trip"); + expect(() => + renderToStaticMarkup(createElement(MantineProvider, { defaultColorScheme: "dark" }, el)), + ).not.toThrow(); + }); +}); diff --git a/plugin/skills/diagram-recipes/component.md b/plugin/skills/diagram-recipes/component.md index a664999..5e5aec5 100644 --- a/plugin/skills/diagram-recipes/component.md +++ b/plugin/skills/diagram-recipes/component.md @@ -112,6 +112,29 @@ route instead). Omit `center` and the map fits all markers. `h` is the height in the map fills its container width and reflows on resize. Put it in a `Grid` beside a legend `Table` so the table becomes the map's key. → `examples/map-routes.component.json`. +**Link pins to cards (`MapLink`).** For a map-plus-list board (itinerary, store finder, day-by-day +plan), tie each card to its pin so hovering one highlights the other. Give the marker an **`id`** and +wrap the matching card in **`MapLink`** with the same `id` (and, if you show more than one linked map +on screen, the same `group`). Hovering/clicking a pin rings its card and scrolls it into view; +hovering a card opens that pin's popup and pans to it. Add **`numbered: true`** on the `Map` to number +pins 1..N in marker order (great for an ordered route). + +```json +{ "type": "Grid", "props": { "gutter": "md" }, "children": [ + { "type": "Grid.Col", "props": { "span": { "base": 12, "md": 7 } }, "children": [ + { "type": "Map", "props": { "h": 320, "numbered": true, "group": "trip", "markers": [ + { "lat": 31.366, "lng": 131.337, "label": "Cape Toi", "color": "#2dd4bf", "id": "day-1" }, + { "lat": 31.797, "lng": 131.478, "label": "Aoshima", "color": "#06b6d4", "id": "day-3" } + ] } } ] }, + { "type": "Grid.Col", "props": { "span": { "base": 12, "md": 5 } }, "children": [ + { "type": "MapLink", "props": { "id": "day-1", "group": "trip" }, + "children": { "type": "Card", "props": { "withBorder": true }, "children": "Day 1 · Cape Toi" } }, + { "type": "MapLink", "props": { "id": "day-3", "group": "trip" }, + "children": { "type": "Card", "props": { "withBorder": true }, "children": "Day 3 · Aoshima" } } + ] } +] } +``` + ### media/video — `component` (`Video` — YouTube-style thumbnail card) **When to use:** referencing a video (a tutorial, a demo, a recording) — show a real **thumbnail card with a play button**, not a bare link. Give it a `url`; for YouTube the poster + watch link +