Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/viewer/src/client/renderers/component-resolver.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -43,6 +44,8 @@ const REGISTRY: Record<string, ComponentType<any>> = {
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)
Expand Down
47 changes: 47 additions & 0 deletions packages/viewer/src/client/renderers/map-link-card.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div
ref={ref}
className={active ? "tc-maplink tc-maplink--active" : "tc-maplink"}
data-map-id={id}
tabIndex={0}
onMouseEnter={() => emitFocus(group, { id, source: "card" })}
onMouseLeave={() => emitFocus(group, { id: null, source: "card" })}
onFocus={() => emitFocus(group, { id, source: "card" })}
>
{children}
</div>
);
}
40 changes: 40 additions & 0 deletions packages/viewer/src/client/renderers/map-link.ts
Original file line number Diff line number Diff line change
@@ -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<string, Set<Listener>>();

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);
}
64 changes: 56 additions & 8 deletions packages/viewer/src/client/renderers/map-node.tsx
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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 {
Expand All @@ -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). */
Expand All @@ -67,26 +75,42 @@ 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
? `<circle cx="13" cy="13" r="7.5" fill="#ffffff"/>` +
`<text x="13" y="16.5" text-anchor="middle" font-size="10" font-weight="700" ` +
`fill="${fill}" font-family="sans-serif">${num}</text>`
: `<circle cx="13" cy="13" r="5" fill="#ffffff" fill-opacity="0.9"/>`;
return (
`<svg width="26" height="38" viewBox="0 0 26 38" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">` +
`<path d="M13 0C5.82 0 0 5.82 0 13c0 9.25 13 25 13 25s13-15.75 13-25C26 5.82 20.18 0 13 0z" ` +
`fill="${fill}" stroke="#ffffff" stroke-width="1.5"/>` +
`<circle cx="13" cy="13" r="5" fill="#ffffff" fill-opacity="0.9"/>` +
badge +
`</svg>`
);
}

const OSM_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const OSM_ATTR = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
let map: import("leaflet").Map | undefined;
let ro: ResizeObserver | undefined;
let unsub: (() => void) | undefined;
let cancelled = false;

(async () => {
Expand All @@ -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<string, import("leaflet").Marker>();
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.
Expand Down Expand Up @@ -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 (
<div
Expand Down
8 changes: 8 additions & 0 deletions packages/viewer/src/client/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,14 @@ body:has(.pane--max) #diagram { transform: none !important; }
.tc-video-title { font-weight: 600; font-size: 14px; color: var(--fg); line-height: 1.3; }
.tc-video-channel { font-size: 12px; color: var(--muted); margin-top: 2px; }

/* MapLink — a card linked to a Map marker by a shared id. Hovering the card (or its linked pin)
rings it; the ring uses the accent so it reads in every theme. border-radius matches Mantine
Card so the ring hugs the card edge. */
.tc-maplink { border-radius: 10px; transition: box-shadow .15s ease, background .15s ease; }
.tc-maplink:hover { box-shadow: 0 0 0 2px var(--accent); }
.tc-maplink--active { box-shadow: 0 0 0 2px var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
.tc-maplink:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }

/* Markdown content (markdown type) */
.markdown-body { color: var(--fg); line-height: 1.6; max-width: 80ch; font-family: var(--font-sans); }
.markdown-body > :first-child { margin-top: 0; }
Expand Down
89 changes: 89 additions & 0 deletions packages/viewer/test/map-link.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
23 changes: 23 additions & 0 deletions plugin/skills/diagram-recipes/component.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 +
Expand Down
Loading