mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 13:17:40 +02:00
[eric] chat: AskUI blocking channel (interactive tool-ui answers flow back as tool results) + restore audio/chart/code-block/code-diff/geo-map + zod-generated prop hints
This commit is contained in:
@@ -82,10 +82,11 @@ def build_effective_tool_lists(
|
||||
|
||||
if name == "openswarm-ui":
|
||||
policy = builtin_perms.get("ShowUI", "always_allow")
|
||||
if policy == "always_allow":
|
||||
effective_allowed.append("mcp__openswarm-ui__ShowUI")
|
||||
else:
|
||||
effective_disallowed.append("mcp__openswarm-ui__ShowUI")
|
||||
for ui_tool in ("ShowUI", "AskUI"):
|
||||
if policy == "always_allow":
|
||||
effective_allowed.append(f"mcp__openswarm-ui__{ui_tool}")
|
||||
else:
|
||||
effective_disallowed.append(f"mcp__openswarm-ui__{ui_tool}")
|
||||
continue
|
||||
|
||||
if name == "openswarm-web":
|
||||
|
||||
@@ -159,16 +159,20 @@ def register_builtin_mcp_servers(
|
||||
"type": "stdio",
|
||||
}
|
||||
|
||||
# Display-only ShowUI server: renders rich inline components (weather, plan, stats, links)
|
||||
# in the transcript. Pure display, no state mutation; the frontend renders from the
|
||||
# tool_call input, the server only validates. Gated on the ShowUI builtin perm.
|
||||
# ShowUI renders rich inline components from the tool_call input (display only, server just
|
||||
# validates); AskUI renders an interactive component and BLOCKS on /api/ui-requests/wait until
|
||||
# the user answers in the transcript. Gated on the ShowUI builtin perm.
|
||||
show_ui_denied = builtin_perms.get("ShowUI", "always_allow") == "deny"
|
||||
if not show_ui_denied:
|
||||
show_ui_server_path = os.path.join(agents_dir, "show_ui_mcp_server.py")
|
||||
mcp_servers["openswarm-ui"] = {
|
||||
"command": sys.executable,
|
||||
"args": [show_ui_server_path],
|
||||
"env": {},
|
||||
"env": {
|
||||
"OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"),
|
||||
"OPENSWARM_AUTH_TOKEN": get_auth_token(),
|
||||
"OPENSWARM_PARENT_SESSION_ID": session.id,
|
||||
},
|
||||
"type": "stdio",
|
||||
}
|
||||
|
||||
|
||||
@@ -7,40 +7,84 @@ backend round-trip and nothing here can mutate state.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
|
||||
PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "")
|
||||
WAIT_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/ui-requests/wait"
|
||||
ASK_TIMEOUT_S = 600
|
||||
|
||||
MAX_PROPS_BYTES = 20_000
|
||||
|
||||
COMPONENT_SPECS = {
|
||||
"weather": "props: {location: str, temp: number, unit?: 'F'|'C', high?: number, low?: number, condition?: str, forecast?: [{day: str, condition?: str, high: number, low?: number}] (max 7)}",
|
||||
"plan": "props: {title?: str, steps: [{label: str, status: 'pending'|'in_progress'|'completed'}] (max 20)}",
|
||||
"stats": "props: {title?: str, stats: [{label: str, value: str, delta?: str, direction?: 'up'|'down'}] (max 8)}",
|
||||
"links": "props: {links: [{title: str, url: str, description?: str}] (max 10)}",
|
||||
# tool-ui vendored set: props follow the upstream Serializable contracts (https://tool-ui.com);
|
||||
# the client validates strictly and shows a validation note instead of rendering on mismatch.
|
||||
"data-table": "tabular results. props: {id: str, columns: [{key: str, label: str}], data: [{<key>: str|number|bool}]}",
|
||||
"citation": "sourced claims. props: {id: str, citations: [{id: str, title: str, url?: str, snippet?: str}]}",
|
||||
"item-carousel": "browsable items. props: {id: str, items: [{id: str, title: str, description?: str, imageUrl?: str, badge?: str}]}",
|
||||
"link-preview": "one rich link card. props: {id: str, url: str, title: str, description?: str, imageUrl?: str, siteName?: str}",
|
||||
"progress-tracker": "multi-stage progress. props: {id: str, stages: [{id: str, label: str, status: 'pending'|'active'|'complete'|'error'}]}",
|
||||
"order-summary": "purchase/receipt breakdown. props: {id: str, items: [{id: str, label: str, amount: number}], total?: number, currency?: str}",
|
||||
"terminal": "command output. props: {id: str, command?: str, output: str}",
|
||||
"image": "single image. props: {id: str, src: str, alt?: str, caption?: str}",
|
||||
"image-gallery": "several images. props: {id: str, images: [{src: str, alt?: str}]}",
|
||||
"video": "video embed. props: {id: str, src: str, poster?: str, title?: str}",
|
||||
"message-draft": "email/message draft for review. props: {id: str, to?: [str], subject?: str, body: str}",
|
||||
"x-post": "an X/Twitter post preview. props: {id: str, author: {name: str, handle: str}, text: str}",
|
||||
"linkedin-post": "a LinkedIn post preview. props: {id: str, author: {name: str, headline?: str}, text: str}",
|
||||
"instagram-post": "an Instagram post preview. props: {id: str, username: str, imageUrl: str, caption?: str}",
|
||||
"option-list": "choices for the user (display for now). props: {id: str, options: [{id: str, label: str, description?: str}], selectionMode?: 'single'|'multi'}",
|
||||
"question-flow": "step-by-step question sequence (display for now). props follow the upstream question-flow contract",
|
||||
"parameter-slider": "adjustable parameters (display for now). props: {id: str, parameters: [{id: str, label: str, min: number, max: number, value: number, step?: number}]}",
|
||||
"preferences-panel": "grouped preference toggles (display for now). props follow the upstream preferences-panel contract",
|
||||
"approval-card": "an approve/reject summary card. props follow the upstream approval-card contract",
|
||||
"stats-display": "upstream stats-display contract (prefer 'stats' unless you need its exact shape)",
|
||||
# Vendored tool-ui set (MIT, https://tool-ui.com); hints are AUTO-GENERATED from the shipped zod
|
||||
# contracts by frontend/scripts/gen-toolui-hints.ts. Regenerate after upgrading src/toolui.
|
||||
"approval-card": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', title: str, description?: str, icon?: str, metadata?: [{key: str, value: str}], variant?: 'default'|'destructive', confirmLabel?: str, cancelLabel?: str, choice?: 'approved'|'denied'}",
|
||||
"audio": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, assetId: str, src: str, title?: str, description?: str, artwork?: str, durationMs?: num, fileSizeBytes?: num, createdAt?: str, locale?: str, source?: {label: str, iconUrl?: str, u...",
|
||||
"chart": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, type: 'bar'|'line', title?: str, description?: str, data: [{}], xKey: str, series: [{key: str, label: str, color?: str}], colors?: [str], showLegend?: bool, showGrid?: bool}",
|
||||
"citation": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, href: str, title: str, snippet?: str, domain?: str, favicon?: str, author?: str, publishedAt?: str, type?: 'webpage'|'document'|'article'|'api'|'code'|'other', locale?: str}",
|
||||
"code-block": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, code: str, language?: str, lineNumbers?: 'visible'|'hidden', filename?: str, highlightLines?: [num], maxCollapsedLines?: num}",
|
||||
"code-diff": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, oldCode?: str, newCode?: str, patch?: str, language?: str, filename?: str, lineNumbers?: 'visible'|'hidden', diffStyle?: 'unified'|'split', maxCollapsedLines?: num}",
|
||||
"data-table": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, columns: [{key: str, label: str, abbr?: str, sortable?: bool, align?: 'left'|'right'|'center', width?: str, truncate?: bool, priority?: 'primary'|'secondary'|'tertiary', hideOnMob...",
|
||||
"geo-map": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, title?: str, description?: str, markers: [{id?: str, lat: num, lng: num, label?: str, description?: str, tooltip?: 'none'|'hover'|'always', icon?: obj|obj|obj}], routes?: [{id?: s...",
|
||||
"image": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, assetId: str, src: str, alt: str, title?: str, description?: str, href?: str, domain?: str, ratio?: 'auto'|'1:1'|'4:3'|'16:9'|'9:16', fit?: 'cover'|'contain', fileSizeBytes?: num,...",
|
||||
"image-gallery": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, images: [{id: str, src: str, alt: str, width: num, height: num, title?: str, caption?: str, source?: obj}], title?: str, description?: str}",
|
||||
"instagram-post": "props: {id: str, author: {name: str, handle: str, avatarUrl: str, verified?: bool}, text?: str, media?: [{type: 'image'|'video', url: str, alt: str}], stats?: {likes?: num, isLiked?: bool}, createdAt?: str}",
|
||||
"item-carousel": "props: {id: str, name: str, subtitle?: str, image?: str, color?: str, actions?: [{id: str, label: str, sentence?: str, confirmLabel?: str, variant?: 'default'|'destructive'|'secondary'|'ghost'|'outline', loading?: bool, disabled?: bool, shortcut?: str}]}",
|
||||
"link-preview": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, href: str, title?: str, description?: str, image?: str, domain?: str, favicon?: str, ratio?: 'auto'|'1:1'|'4:3'|'16:9'|'9:16', fit?: 'cover'|'contain', createdAt?: str, locale?: str}",
|
||||
"linkedin-post": "props: {id: str, author: {name: str, avatarUrl: str, headline?: str}, text?: str, media?: {type: 'image'|'video', url: str, alt: str}, linkPreview?: {url: str, title?: str, description?: str, imageUrl?: str, domain?: str}, stats?: {likes?: num, isLiked?: bool}, createdAt?: str}",
|
||||
"message-draft": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', body: str, outcome?: 'sent'|'cancelled', channel: str, subject: str, from?: str, to: [str], cc?: [str], bcc?: [str]}",
|
||||
"option-list": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, options: [{id: str, label: str, description?: str, disabled?: bool}], selectionMode?: 'multi'|'single', defaultValue?: [str]|str|any, choice?: [str]|str|any, actions?: [{id: str, ...",
|
||||
"order-summary": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', title?: str, variant?: 'summary'|'receipt', items: [{id: str, name: str, description?: str, imageUrl?: str, quantity?: num, unitPrice: num}], pricing: {subtotal: num, tax?: num, taxLabel?: str, shipping?: num, discount?: num, discountLabel?: str, total: num, currency?: str}, choice?:...",
|
||||
"parameter-slider": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', sliders: [{id: str, label: str, min: num, max: num, step?: num, value: num, unit?: str, precision?: num, disabled?: bool, trackClassName?: str, fillClassName?: str, handleClassName?: str}], actions?: [{id: str, label: str, sentence?: str, confirmLabel?: str, variant?: 'default'|'dest...",
|
||||
"plan": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, title: str, description?: str, todos: [{id: str, label: str, status: 'pending'|'in_progress'|'completed'|'cancelled', description?: str}], maxVisibleTodos?: num}",
|
||||
"preferences-panel": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, title?: str, sections: [{heading?: str, items: [any]}], actions?: [{id: str, label: str, sentence?: str, confirmLabel?: str, variant?: 'default'|'destructive'|'secondary'|'ghost'|...",
|
||||
"progress-tracker": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', steps: [{id: str, label: str, description?: str, status: 'pending'|'in-progress'|'completed'|'failed'}], elapsedTime?: num, choice?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}}",
|
||||
"question-flow": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', step: num, title: str, description?: str, options: [{id: str, label: str, description?: str, disabled?: bool}], selectionMode?: 'single'|'multi'}",
|
||||
"stats-display": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', title?: str, description?: str, stats: [{key: str, label: str, value: str|num, format?: any, diff?: obj, sparkline?: obj}]}",
|
||||
"terminal": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, command: str, stdout?: str, stderr?: str, exitCode: num, durationMs?: num, cwd?: str, truncated?: bool, maxCollapsedLines?: num}",
|
||||
"video": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, assetId: str, src: str, poster?: str, title?: str, description?: str, href?: str, domain?: str, durationMs?: num, ratio?: 'auto'|'1:1'|'4:3'|'16:9'|'9:16', fit?: 'cover'|'contain'...",
|
||||
"x-post": "props: {id: str, author: {name: str, handle: str, avatarUrl: str, verified?: bool}, text?: str, media?: {type: 'image'|'video', url: str, alt: str, aspectRatio?: '1:1'|'4:3'|'16:9'|'9:16'}, linkPreview?: {url: str, title?: str, description?: str, imageUrl?: str, domain?: str}, quotedPost?: any, stats?: {likes?: num, isLiked?: bool, isReposted?: bool, isBookmarke...",
|
||||
}
|
||||
|
||||
INTERACTIVE_COMPONENTS = (
|
||||
"option-list", "question-flow", "parameter-slider", "preferences-panel", "approval-card",
|
||||
)
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "AskUI",
|
||||
"description": (
|
||||
"Render an INTERACTIVE component in the chat and WAIT for the user's answer (up to 10 "
|
||||
"minutes); the tool result is their response. Use this instead of plain-text questions "
|
||||
"when the choice fits a component. Components: "
|
||||
+ ", ".join(f"'{name}'" for name in INTERACTIVE_COMPONENTS)
|
||||
+ ". Props follow the same shapes as ShowUI (props.id is REQUIRED, it correlates the "
|
||||
"answer). The response contains the action taken and the user's selection/values."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"component": {
|
||||
"type": "string",
|
||||
"enum": list(INTERACTIVE_COMPONENTS),
|
||||
"description": "Which interactive component to render.",
|
||||
},
|
||||
"props": {
|
||||
"type": "object",
|
||||
"description": "Data for the component; must include a stable string id.",
|
||||
},
|
||||
},
|
||||
"required": ["component", "props"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "ShowUI",
|
||||
"description": (
|
||||
@@ -90,8 +134,6 @@ def validate(component: str, props: dict) -> str:
|
||||
return f"props too large ({size} bytes; max {MAX_PROPS_BYTES})."
|
||||
if component == "weather" and not (isinstance(props.get("location"), str) and isinstance(props.get("temp"), (int, float))):
|
||||
return f"weather needs at least location + temp. {COMPONENT_SPECS['weather']}"
|
||||
if component == "plan" and not (isinstance(props.get("steps"), list) and props["steps"]):
|
||||
return f"plan needs a non-empty steps list. {COMPONENT_SPECS['plan']}"
|
||||
if component == "stats" and not (isinstance(props.get("stats"), list) and props["stats"]):
|
||||
return f"stats needs a non-empty stats list. {COMPONENT_SPECS['stats']}"
|
||||
if component == "links" and not (isinstance(props.get("links"), list) and props["links"]):
|
||||
@@ -101,7 +143,46 @@ def validate(component: str, props: dict) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def p_post(url: str, body: dict, timeout: float) -> dict:
|
||||
payload = json.dumps(body).encode()
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if BACKEND_AUTH:
|
||||
headers["Authorization"] = f"Bearer {BACKEND_AUTH}"
|
||||
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
body_txt = e.read().decode(errors="replace") if e.fp else str(e)
|
||||
return {"error": f"HTTP {e.code}: {body_txt[:300]}"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def handle_ask_ui(arguments: dict) -> dict:
|
||||
component = str(arguments.get("component", "")).strip()
|
||||
props = arguments.get("props")
|
||||
if not isinstance(props, dict):
|
||||
return {"content": [{"type": "text", "text": "props must be an object."}], "isError": True}
|
||||
if component not in INTERACTIVE_COMPONENTS:
|
||||
return {"content": [{"type": "text", "text": f"AskUI only supports: {', '.join(INTERACTIVE_COMPONENTS)}. Use ShowUI for display-only components."}], "isError": True}
|
||||
component_id = str(props.get("id", "")).strip()
|
||||
if not component_id:
|
||||
return {"content": [{"type": "text", "text": "props.id (a stable string) is required so the answer can be correlated."}], "isError": True}
|
||||
problem = validate(component, props)
|
||||
if problem:
|
||||
return {"content": [{"type": "text", "text": f"Not rendered: {problem}"}], "isError": True}
|
||||
r = p_post(WAIT_URL, {"session_id": PARENT_SESSION_ID, "component_id": component_id, "timeout_s": ASK_TIMEOUT_S}, timeout=ASK_TIMEOUT_S + 20)
|
||||
if "error" in r:
|
||||
return {"content": [{"type": "text", "text": f"AskUI failed: {r['error']}"}], "isError": True}
|
||||
if not r.get("ok"):
|
||||
return {"content": [{"type": "text", "text": "The user didn't respond within 10 minutes. Continue without their input or ask again."}], "isError": True}
|
||||
return {"content": [{"type": "text", "text": json.dumps(r.get("response"))}]}
|
||||
|
||||
|
||||
def handle_tool_call(tool_name: str, arguments: dict) -> dict:
|
||||
if tool_name == "AskUI":
|
||||
return handle_ask_ui(arguments)
|
||||
if tool_name != "ShowUI":
|
||||
return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True}
|
||||
component = str(arguments.get("component", "")).strip()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Blocking bridge for interactive tool-ui components: AskUI parks here until the user
|
||||
answers in the transcript (or the wait times out). Keyed by (session_id, component props.id),
|
||||
so the frontend can respond without ever learning a server-side request id."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, InstanceOf
|
||||
from typeguard import typechecked
|
||||
|
||||
MAX_PENDING = 50
|
||||
MAX_WAIT_SECONDS = 600.0
|
||||
|
||||
|
||||
class PendingUiRequest(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
event: InstanceOf[asyncio.Event]
|
||||
response: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
p_pending: Dict[Tuple[str, str], PendingUiRequest] = {}
|
||||
|
||||
|
||||
@typechecked
|
||||
async def wait_for_ui_response(session_id: str, component_id: str, timeout_s: float) -> Optional[Dict[str, Any]]:
|
||||
"""Registers the request and blocks until respond_to_ui_request fires it; None on timeout."""
|
||||
if len(p_pending) >= MAX_PENDING:
|
||||
raise ValueError("too many pending UI requests")
|
||||
key = (session_id, component_id)
|
||||
# A retried tool call for the same component replaces the stale wait; the old waiter times out.
|
||||
pending = PendingUiRequest(event=asyncio.Event())
|
||||
p_pending[key] = pending
|
||||
try:
|
||||
await asyncio.wait_for(pending.event.wait(), timeout=min(timeout_s, MAX_WAIT_SECONDS))
|
||||
return pending.response
|
||||
except asyncio.TimeoutError:
|
||||
return None
|
||||
finally:
|
||||
if p_pending.get(key) is pending:
|
||||
p_pending.pop(key, None)
|
||||
|
||||
|
||||
@typechecked
|
||||
def respond_to_ui_request(session_id: str, component_id: str, response: Dict[str, Any]) -> bool:
|
||||
"""Delivers the user's answer to the parked wait; False when nothing is waiting."""
|
||||
pending = p_pending.get((session_id, component_id))
|
||||
if pending is None:
|
||||
return False
|
||||
pending.response = response
|
||||
pending.event.set()
|
||||
return True
|
||||
|
||||
|
||||
@typechecked
|
||||
def has_pending_ui_request(session_id: str, component_id: str) -> bool:
|
||||
return (session_id, component_id) in p_pending
|
||||
@@ -902,6 +902,43 @@ async def spawn_agent_run(request: Request):
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.post("/api/ui-requests/wait")
|
||||
async def ui_request_wait(request: Request):
|
||||
"""AskUI's blocking half: parks until the user answers the interactive component in the
|
||||
transcript. Called by the show_ui_mcp_server stdio subprocess."""
|
||||
body = await request.json()
|
||||
session_id = str(body.get("session_id", ""))
|
||||
component_id = str(body.get("component_id", ""))
|
||||
timeout_s = float(body.get("timeout_s", 600) or 600)
|
||||
if not session_id or not component_id:
|
||||
return JSONResponse({"error": "session_id and component_id are required"}, status_code=400)
|
||||
try:
|
||||
from backend.apps.agents.ui_request_bridge import wait_for_ui_response
|
||||
response = await wait_for_ui_response(session_id, component_id, timeout_s)
|
||||
return JSONResponse({"ok": response is not None, "response": response})
|
||||
except ValueError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=429)
|
||||
except Exception as e:
|
||||
logger.exception("ui_request_wait failed")
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.post("/api/ui-requests/respond")
|
||||
async def ui_request_respond(request: Request):
|
||||
"""The user's answer from the rendered component; releases the matching parked wait."""
|
||||
body = await request.json()
|
||||
session_id = str(body.get("session_id", ""))
|
||||
component_id = str(body.get("component_id", ""))
|
||||
response = body.get("response")
|
||||
if not session_id or not component_id or not isinstance(response, dict):
|
||||
return JSONResponse({"error": "session_id, component_id and response object are required"}, status_code=400)
|
||||
from backend.apps.agents.ui_request_bridge import respond_to_ui_request
|
||||
delivered = respond_to_ui_request(session_id, component_id, response)
|
||||
if not delivered:
|
||||
return JSONResponse({"error": "no pending request for that component"}, status_code=404)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.post("/api/invoke-agent/run")
|
||||
async def invoke_agent_run(request: Request):
|
||||
"""Fork an existing agent session and send it a new message.
|
||||
|
||||
Generated
+839
-39
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.9",
|
||||
"@mui/material": "^7.3.9",
|
||||
"@pierre/diffs": "^1.0.11",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"ansi-to-react": "^6.2.6",
|
||||
@@ -27,16 +28,20 @@
|
||||
"codemirror": "^6.0.2",
|
||||
"framer-motion": "^12.35.2",
|
||||
"html-to-image": "^1.11.13",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.17.0",
|
||||
"radix-ui": "^1.6.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"recharts": "^3.9.2",
|
||||
"recharts": "^2.15.4",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^3.23.0",
|
||||
"supercluster": "^8.0.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -46,9 +51,11 @@
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/react-redux": "^7.1.34",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"babel-loader": "^9.2.1",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"css-loader": "^6.8.0",
|
||||
@@ -60,6 +67,7 @@
|
||||
"sass-loader": "^16.0.5",
|
||||
"style-loader": "^3.3.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.23.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.0.0",
|
||||
"webpack": "^5.88.0",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/* Generates terse per-component prop hints for the ShowUI tool description straight from the
|
||||
vendored tool-ui zod contracts, so the server spec can never drift from what validates.
|
||||
Run: npx tsx --tsconfig tsconfig.json scripts/gen-toolui-hints.ts */
|
||||
import { z } from 'zod';
|
||||
|
||||
const TARGETS: Record<string, [string, string]> = {
|
||||
'approval-card': ['../src/toolui/components/approval-card/schema', 'SerializableApprovalCardSchema'],
|
||||
'audio': ['../src/toolui/components/audio/schema', 'SerializableAudioSchema'],
|
||||
'chart': ['../src/toolui/components/chart/schema', 'SerializableChartSchema'],
|
||||
'citation': ['../src/toolui/components/citation/schema', 'SerializableCitationSchema'],
|
||||
'code-block': ['../src/toolui/components/code-block/schema', 'SerializableCodeBlockSchema'],
|
||||
'code-diff': ['../src/toolui/components/code-diff/schema', 'SerializableCodeDiffSchema'],
|
||||
'data-table': ['../src/toolui/components/data-table/schema', 'SerializableDataTableSchema'],
|
||||
'geo-map': ['../src/toolui/components/geo-map/schema', 'SerializableGeoMapSchema'],
|
||||
'image': ['../src/toolui/components/image/schema', 'SerializableImageSchema'],
|
||||
'image-gallery': ['../src/toolui/components/image-gallery/schema', 'SerializableImageGallerySchema'],
|
||||
'instagram-post': ['../src/toolui/components/instagram-post/schema', 'SerializableInstagramPostSchema'],
|
||||
'item-carousel': ['../src/toolui/components/item-carousel/schema', 'SerializableItemSchema'],
|
||||
'link-preview': ['../src/toolui/components/link-preview/schema', 'SerializableLinkPreviewSchema'],
|
||||
'linkedin-post': ['../src/toolui/components/linkedin-post/schema', 'SerializableLinkedInPostSchema'],
|
||||
'message-draft': ['../src/toolui/components/message-draft/schema', 'SerializableEmailDraftSchema'],
|
||||
'option-list': ['../src/toolui/components/option-list/schema', 'SerializableOptionListSchema'],
|
||||
'order-summary': ['../src/toolui/components/order-summary/schema', 'SerializableOrderSummarySchema'],
|
||||
'parameter-slider': ['../src/toolui/components/parameter-slider/schema', 'SerializableParameterSliderSchema'],
|
||||
'plan': ['../src/toolui/components/plan/schema', 'SerializablePlanSchema'],
|
||||
'preferences-panel': ['../src/toolui/components/preferences-panel/schema', 'SerializablePreferencesPanelSchema'],
|
||||
'progress-tracker': ['../src/toolui/components/progress-tracker/schema', 'SerializableProgressTrackerSchema'],
|
||||
'question-flow': ['../src/toolui/components/question-flow/schema', 'SerializableProgressiveModeSchema'],
|
||||
'stats-display': ['../src/toolui/components/stats-display/schema', 'SerializableStatsDisplaySchema'],
|
||||
'terminal': ['../src/toolui/components/terminal/schema', 'SerializableTerminalSchema'],
|
||||
'video': ['../src/toolui/components/video/schema', 'SerializableVideoSchema'],
|
||||
'x-post': ['../src/toolui/components/x-post/schema', 'SerializableXPostSchema'],
|
||||
};
|
||||
|
||||
function describe(node: any, depth: number): string {
|
||||
if (!node || typeof node !== 'object') return 'any';
|
||||
if (Array.isArray(node.enum)) return node.enum.map((v: unknown) => `'${v}'`).join('|');
|
||||
if (Array.isArray(node.anyOf)) return node.anyOf.map((n: any) => describe(n, depth)).join('|');
|
||||
const t = node.type;
|
||||
if (t === 'array') return `[${describe(node.items, depth)}]`;
|
||||
if (t === 'object') {
|
||||
if (depth >= 2) return 'obj';
|
||||
const req = new Set(node.required || []);
|
||||
const props = node.properties || {};
|
||||
const parts = Object.keys(props).map((k) => `${k}${req.has(k) ? '' : '?'}: ${describe(props[k], depth + 1)}`);
|
||||
return `{${parts.join(', ')}}`;
|
||||
}
|
||||
if (t === 'string') return 'str';
|
||||
if (t === 'number' || t === 'integer') return 'num';
|
||||
if (t === 'boolean') return 'bool';
|
||||
return 'any';
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [name, [path, exportName]] of Object.entries(TARGETS)) {
|
||||
try {
|
||||
const mod = await import(path);
|
||||
const schema = mod[exportName];
|
||||
const js = z.toJSONSchema(schema, { unrepresentable: 'any', io: 'input' } as any) as any;
|
||||
let hint = describe(js, 0);
|
||||
if (hint.length > 360) hint = hint.slice(0, 357) + '...';
|
||||
out[name] = hint;
|
||||
} catch (e) {
|
||||
out[name] = `ERROR: ${(e as Error).message.slice(0, 80)}`;
|
||||
}
|
||||
}
|
||||
for (const [name, hint] of Object.entries(out)) {
|
||||
console.log(` "${name}": "props: ${hint.replace(/"/g, "'")}",`);
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -59,7 +59,8 @@ import MessageActionBar from './shell/MessageActionBar';
|
||||
import ToolCallBubble, { ToolPair } from './tool-bubbles/ToolCallBubble';
|
||||
import ToolGroupBubble, { RenderItem, ToolGroup, isToolGroup, isToolPair } from './tool-bubbles/ToolGroupBubble';
|
||||
import ToolUiBubble from './tool-ui/ToolUiBubble';
|
||||
import { isShowUiPair } from './tool-ui/showUiPayload';
|
||||
import AskUiBubble from './tool-ui/AskUiBubble';
|
||||
import { isShowUiPair, isAskUiPair } from './tool-ui/showUiPayload';
|
||||
import ApprovalBar, { BatchApprovalBar } from './shell/ApprovalBar';
|
||||
import ForceStopAgentBar from './ForceStopAgentBar';
|
||||
import { RateLimitPill } from './shell/RateLimitPill';
|
||||
@@ -1079,10 +1080,10 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
result: results[idx] || null,
|
||||
}));
|
||||
|
||||
// ShowUI calls render as inline components, never buried inside a collapsed group.
|
||||
// ShowUI/AskUI calls render as inline components, never buried inside a collapsed group.
|
||||
// They typically cap a run of work, so the quiet group row stays above the widget.
|
||||
const showUiPairs = allPairs.filter(isShowUiPair);
|
||||
const pairs = allPairs.filter((p) => !isShowUiPair(p));
|
||||
const showUiPairs = allPairs.filter((p) => isShowUiPair(p) || isAskUiPair(p));
|
||||
const pairs = allPairs.filter((p) => !isShowUiPair(p) && !isAskUiPair(p));
|
||||
const calls = pairs.map((p) => p.call);
|
||||
|
||||
const mcpServers = new Set(
|
||||
@@ -1607,6 +1608,14 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
}
|
||||
if (isToolPair(item)) {
|
||||
const isPending = item.result === null && sessionRunning;
|
||||
if (isAskUiPair(item)) {
|
||||
return (
|
||||
<Box key={item.id} data-window-item-id={item.id} ref={isLastVisibleItem ? lastVisibleItemRef : undefined}>
|
||||
<AskUiBubble pair={item} sessionId={session.id} isPending={isPending} suppressReveal={item.call.id === justStreamedId} />
|
||||
{compactionChip}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (isShowUiPair(item)) {
|
||||
return (
|
||||
<Box key={item.id} data-window-item-id={item.id} ref={isLastVisibleItem ? lastVisibleItemRef : undefined}>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import ToolCallBubble from '../tool-bubbles/ToolCallBubble';
|
||||
import type { ToolPair } from '../tool-bubbles/ToolCallBubble';
|
||||
import { parseShowUiPayload } from './showUiPayload';
|
||||
import VendoredToolUi from '@toolui/VendoredToolUi';
|
||||
import { API_BASE, getAuthToken } from '@/shared/config';
|
||||
|
||||
interface AskUiBubbleProps {
|
||||
pair: ToolPair;
|
||||
sessionId: string;
|
||||
isPending: boolean;
|
||||
suppressReveal: boolean;
|
||||
}
|
||||
|
||||
function parseResultResponse(pair: ToolPair): Record<string, unknown> | null {
|
||||
const rc = pair.result?.content;
|
||||
const text = typeof rc === 'string' ? rc : typeof rc === 'object' && rc?.text ? String(rc.text) : '';
|
||||
if (!text.startsWith('{')) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** An AskUI call: the live interactive component while the agent waits; its answered state after. */
|
||||
function AskUiBubble({ pair, sessionId, isPending, suppressReveal }: AskUiBubbleProps): React.ReactElement {
|
||||
const payload = parseShowUiPayload(pair);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const answered = parseResultResponse(pair);
|
||||
|
||||
const componentId = payload && payload.component === 'vendored' ? String(payload.props.id || '') : '';
|
||||
|
||||
const respond = useCallback(
|
||||
(response: Record<string, unknown>) => {
|
||||
if (submitted) return;
|
||||
setSubmitted(true);
|
||||
void fetch(`${API_BASE}/ui-requests/respond`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getAuthToken()}` },
|
||||
body: JSON.stringify({ session_id: sessionId, component_id: componentId, response }),
|
||||
}).catch(() => setSubmitted(false));
|
||||
},
|
||||
[submitted, sessionId, componentId],
|
||||
);
|
||||
|
||||
// Their embedded-actions contract: onAction(actionId, state) delivers the component's full state;
|
||||
// approval-card uses onConfirm/onCancel instead. Inject a default Send action when none given.
|
||||
const extraProps = useMemo(() => {
|
||||
if (!payload || payload.component !== 'vendored') return {};
|
||||
const waiting = pair.result === null && !submitted;
|
||||
if (payload.name === 'approval-card') {
|
||||
return waiting
|
||||
? {
|
||||
onConfirm: () => respond({ action: 'confirm', choice: 'approved' }),
|
||||
onCancel: () => respond({ action: 'cancel', choice: 'denied' }),
|
||||
}
|
||||
: { choice: (answered?.choice as string) || undefined };
|
||||
}
|
||||
if (waiting) {
|
||||
const hasActions = Array.isArray((payload.props as { actions?: unknown[] }).actions) && (payload.props as { actions?: unknown[] }).actions!.length > 0;
|
||||
return {
|
||||
...(hasActions ? {} : { actions: [{ id: 'submit', label: 'Send' }] }),
|
||||
onAction: (actionId: string, state: unknown) => respond({ action: actionId, value: state ?? null }),
|
||||
};
|
||||
}
|
||||
return answered && 'value' in answered ? { choice: answered.value } : {};
|
||||
}, [payload, pair.result, submitted, respond, answered]);
|
||||
|
||||
if (!payload || payload.component !== 'vendored' || !componentId) {
|
||||
return (
|
||||
<ToolCallBubble call={pair.call} result={pair.result} isPending={isPending} sessionId={sessionId} suppressReveal={suppressReveal} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ my: 1, contain: 'layout style' }} data-select-type="tool-ui-ask" data-select-id={pair.id} data-select-meta={JSON.stringify({ component: payload.name })}>
|
||||
<VendoredToolUi name={payload.name} props={payload.props} extraProps={extraProps} />
|
||||
{submitted && pair.result === null && (
|
||||
<Box sx={{ fontSize: '0.72rem', opacity: 0.55, pt: 0.5 }}>Sent to the agent...</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default AskUiBubble;
|
||||
@@ -70,6 +70,11 @@ export function isShowUiPair(pair: ToolPair): boolean {
|
||||
return /(^|__)ShowUI$/.test(tool);
|
||||
}
|
||||
|
||||
export function isAskUiPair(pair: ToolPair): boolean {
|
||||
const tool = typeof pair.call.content === 'object' ? String(pair.call.content?.tool || '') : '';
|
||||
return /(^|__)AskUI$/.test(tool);
|
||||
}
|
||||
|
||||
/** Latest ShowUI payload anywhere in a transcript; the collapsed card pins this artifact under its pill. */
|
||||
export function extractLatestShowUi(messages: Array<{ role: string; content: any }>): ShowUiPayload | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
|
||||
@@ -5,12 +5,14 @@ import { TOOL_UI_REGISTRY } from './registry';
|
||||
interface VendoredToolUiProps {
|
||||
name: string;
|
||||
props: Record<string, unknown>;
|
||||
/** Non-serializable React props (callbacks, live overrides) merged AFTER validation of the wire props. */
|
||||
extraProps?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type Gate = 'pending' | 'ok' | 'bad';
|
||||
|
||||
/** Validates against the upstream zod contract, then renders the vendored component inside the scoped theme. */
|
||||
function VendoredToolUi({ name, props }: VendoredToolUiProps): React.ReactElement | null {
|
||||
function VendoredToolUi({ name, props, extraProps }: VendoredToolUiProps): React.ReactElement | null {
|
||||
const { mode } = useThemeMode();
|
||||
const entry = TOOL_UI_REGISTRY[name];
|
||||
const [gate, setGate] = useState<Gate>('pending');
|
||||
@@ -50,7 +52,7 @@ function VendoredToolUi({ name, props }: VendoredToolUiProps): React.ReactElemen
|
||||
return (
|
||||
<div className={`tool-ui-scope${mode === 'dark' ? ' dark' : ''}`}>
|
||||
<Suspense fallback={<div style={{ height: 48, width: 280, borderRadius: 12, background: 'rgba(127,127,127,0.12)' }} />}>
|
||||
<Component {...props} />
|
||||
<Component {...props} {...(extraProps || {})} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Audio
|
||||
|
||||
Implementation for the "audio" Tool UI surface.
|
||||
|
||||
## Files
|
||||
|
||||
- public exports: components/tool-ui/audio/index.ts
|
||||
- serializable schema + parse helpers: components/tool-ui/audio/schema.ts
|
||||
|
||||
## Companion assets
|
||||
|
||||
- Docs page: app/docs/audio/content.mdx
|
||||
- Preset payload: lib/presets/audio.ts
|
||||
|
||||
## Quick check
|
||||
|
||||
Run this after edits:
|
||||
|
||||
pnpm test
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Adapter: UI and utility re-exports for copy-standalone portability.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
export { cn } from "@toolui/lib/utils";
|
||||
export { Button } from "@toolui/ui/button";
|
||||
export { Slider } from "@toolui/ui/slider";
|
||||
@@ -0,0 +1,341 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Pause, Play } from "lucide-react";
|
||||
import { cn, Button, Slider } from "./_adapter";
|
||||
|
||||
import { AudioProvider, useAudio } from "./context";
|
||||
import type { SerializableAudio, AudioVariant } from "./schema";
|
||||
|
||||
const FALLBACK_LOCALE = "en-US";
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds)) return "0:00";
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export interface AudioProps extends SerializableAudio {
|
||||
variant?: AudioVariant;
|
||||
className?: string;
|
||||
onMediaEvent?: (type: "play" | "pause" | "mute" | "unmute") => void;
|
||||
}
|
||||
|
||||
export function Audio(props: AudioProps) {
|
||||
return (
|
||||
<AudioProvider>
|
||||
<AudioInner {...props} />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface PlayerControls {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onPlayPause: () => void;
|
||||
onSeek: (value: number[]) => void;
|
||||
onSeekStart: () => void;
|
||||
onSeekEnd: () => void;
|
||||
}
|
||||
|
||||
interface FullPlayerProps {
|
||||
artwork?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
controls: PlayerControls;
|
||||
}
|
||||
|
||||
function FullPlayer({
|
||||
artwork,
|
||||
title,
|
||||
description,
|
||||
controls,
|
||||
}: FullPlayerProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
{artwork && (
|
||||
<div className="bg-muted relative aspect-[4/3] w-full overflow-hidden">
|
||||
<img
|
||||
src={artwork}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-5 p-4">
|
||||
{(title || description) && (
|
||||
<div className="space-y-0.5">
|
||||
{title && (
|
||||
<div className="text-foreground line-clamp-2 font-semibold leading-snug">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<div className="text-muted-foreground line-clamp-2 text-sm leading-snug">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Slider
|
||||
value={[controls.currentTime]}
|
||||
max={controls.duration || 100}
|
||||
step={0.1}
|
||||
onValueChange={controls.onSeek}
|
||||
onPointerDown={controls.onSeekStart}
|
||||
onPointerUp={controls.onSeekEnd}
|
||||
className="cursor-pointer [&_[data-slot=range]]:bg-foreground [&_[data-slot=thumb]]:size-3 [&_[data-slot=thumb]]:border-2 [&_[data-slot=thumb]]:border-background [&_[data-slot=thumb]]:bg-foreground"
|
||||
aria-label="Audio progress"
|
||||
/>
|
||||
<div className="text-muted-foreground flex items-center justify-between text-xs tabular-nums">
|
||||
<span>{formatTime(controls.currentTime)}</span>
|
||||
<span>{formatTime(controls.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
onClick={controls.onPlayPause}
|
||||
className="-mt-4 size-10 shrink-0 rounded-full"
|
||||
aria-label={controls.isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{controls.isPlaying ? (
|
||||
<Pause className="size-4" fill="currentColor" />
|
||||
) : (
|
||||
<Play className="size-4 ml-0.5" fill="currentColor" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CompactPlayerProps {
|
||||
artwork?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
controls: PlayerControls;
|
||||
}
|
||||
|
||||
function CompactPlayer({
|
||||
artwork,
|
||||
title,
|
||||
description,
|
||||
controls,
|
||||
}: CompactPlayerProps) {
|
||||
const progress =
|
||||
controls.duration > 0
|
||||
? (controls.currentTime / controls.duration) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full items-center gap-3 overflow-hidden p-3">
|
||||
{artwork && (
|
||||
<>
|
||||
<img
|
||||
src={artwork}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -left-1/4 top-1/2 h-[200%] w-auto -translate-y-1/2 object-cover opacity-40 blur-2xl saturate-150"
|
||||
/>
|
||||
<div className="from-card/60 to-card/90 pointer-events-none absolute inset-0 bg-gradient-to-r" />
|
||||
</>
|
||||
)}
|
||||
{artwork && (
|
||||
<div className="ring-background/20 relative size-12 shrink-0 overflow-hidden rounded-lg shadow-lg ring-1">
|
||||
<img
|
||||
src={artwork}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative flex min-w-0 flex-1 flex-col justify-center">
|
||||
{title && (
|
||||
<div className="text-foreground truncate text-sm font-semibold leading-tight">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<div className="text-muted-foreground mt-0.5 truncate text-xs leading-tight">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{controls.duration > 0 && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<div className="bg-foreground/20 relative h-1 flex-1 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="bg-foreground absolute inset-y-0 left-0 rounded-full transition-all duration-150"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatTime(controls.currentTime)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
onClick={controls.onPlayPause}
|
||||
className="relative size-10 shrink-0 rounded-full shadow-md"
|
||||
aria-label={controls.isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{controls.isPlaying ? (
|
||||
<Pause className="size-4" fill="currentColor" />
|
||||
) : (
|
||||
<Play className="size-4 ml-0.5" fill="currentColor" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AudioInner(props: AudioProps) {
|
||||
const { variant = "full", className, onMediaEvent, ...serializable } = props;
|
||||
|
||||
const {
|
||||
id,
|
||||
src,
|
||||
title,
|
||||
description,
|
||||
artwork,
|
||||
locale: providedLocale,
|
||||
} = serializable;
|
||||
|
||||
const locale = providedLocale ?? FALLBACK_LOCALE;
|
||||
|
||||
const { state, setState, setAudioElement } = useAudio();
|
||||
const audioRef = React.useRef<HTMLAudioElement | null>(null);
|
||||
const [currentTime, setCurrentTime] = React.useState(0);
|
||||
const [duration, setDuration] = React.useState(0);
|
||||
const [isSeeking, setIsSeeking] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setAudioElement(audioRef.current);
|
||||
return () => setAudioElement(null);
|
||||
}, [setAudioElement]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (state.playing && audio.paused) {
|
||||
void audio.play().catch(() => undefined);
|
||||
} else if (!state.playing && !audio.paused) {
|
||||
audio.pause();
|
||||
}
|
||||
}, [state.playing]);
|
||||
|
||||
const handlePlayPause = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (audio.paused) {
|
||||
void audio.play().catch(() => undefined);
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeek = (value: number[]) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const newTime = value[0];
|
||||
audio.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
};
|
||||
|
||||
const handleSeekStart = () => {
|
||||
setIsSeeking(true);
|
||||
};
|
||||
|
||||
const handleSeekEnd = () => {
|
||||
setIsSeeking(false);
|
||||
};
|
||||
|
||||
const controls: PlayerControls = {
|
||||
isPlaying: state.playing,
|
||||
currentTime,
|
||||
duration,
|
||||
onPlayPause: handlePlayPause,
|
||||
onSeek: handleSeek,
|
||||
onSeekStart: handleSeekStart,
|
||||
onSeekEnd: handleSeekEnd,
|
||||
};
|
||||
|
||||
const isCompact = variant === "compact";
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"@container/actions relative w-full",
|
||||
isCompact ? "min-w-72 max-w-md" : "min-w-52 max-w-sm",
|
||||
className,
|
||||
)}
|
||||
lang={locale}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="audio"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden",
|
||||
"border-border bg-card border text-sm shadow-xs",
|
||||
"rounded-xl",
|
||||
)}
|
||||
>
|
||||
{isCompact ? (
|
||||
<CompactPlayer
|
||||
artwork={artwork}
|
||||
title={title}
|
||||
description={description}
|
||||
controls={controls}
|
||||
/>
|
||||
) : (
|
||||
<FullPlayer
|
||||
artwork={artwork}
|
||||
title={title}
|
||||
description={description}
|
||||
controls={controls}
|
||||
/>
|
||||
)}
|
||||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={src}
|
||||
preload="metadata"
|
||||
className="hidden"
|
||||
onPlay={() => {
|
||||
setState({ playing: true });
|
||||
onMediaEvent?.("play");
|
||||
}}
|
||||
onPause={() => {
|
||||
setState({ playing: false });
|
||||
onMediaEvent?.("pause");
|
||||
}}
|
||||
onTimeUpdate={(event) => {
|
||||
if (!isSeeking) {
|
||||
setCurrentTime(event.currentTarget.currentTime);
|
||||
}
|
||||
}}
|
||||
onLoadedMetadata={(event) => {
|
||||
setDuration(event.currentTarget.duration);
|
||||
}}
|
||||
onDurationChange={(event) => {
|
||||
setDuration(event.currentTarget.duration);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
export interface AudioPlaybackState {
|
||||
playing: boolean;
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
export interface AudioContextValue {
|
||||
state: AudioPlaybackState;
|
||||
setState: (patch: Partial<AudioPlaybackState>) => void;
|
||||
audioElement: HTMLAudioElement | null;
|
||||
setAudioElement: (node: HTMLAudioElement | null) => void;
|
||||
}
|
||||
|
||||
const AudioContext = React.createContext<AudioContextValue | null>(null);
|
||||
|
||||
export function useAudio() {
|
||||
const ctx = React.useContext(AudioContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAudio must be used within an <AudioProvider />");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export interface AudioProviderProps {
|
||||
children: React.ReactNode;
|
||||
defaultState?: Partial<AudioPlaybackState>;
|
||||
}
|
||||
|
||||
export function AudioProvider({ children, defaultState }: AudioProviderProps) {
|
||||
const [state, setStateInternal] = React.useState<AudioPlaybackState>({
|
||||
playing: defaultState?.playing ?? false,
|
||||
muted: defaultState?.muted ?? false,
|
||||
});
|
||||
|
||||
const [audioElement, setAudioElement] =
|
||||
React.useState<HTMLAudioElement | null>(null);
|
||||
|
||||
const setState = React.useCallback((patch: Partial<AudioPlaybackState>) => {
|
||||
setStateInternal((prev) => ({ ...prev, ...patch }));
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ state, setState, audioElement, setAudioElement }),
|
||||
[state, setState, audioElement],
|
||||
);
|
||||
|
||||
return (
|
||||
<AudioContext.Provider value={value}>{children}</AudioContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { Audio } from "./audio";
|
||||
export type { AudioProps } from "./audio";
|
||||
export { AudioProvider, useAudio } from "./context";
|
||||
export type { AudioPlaybackState, AudioContextValue } from "./context";
|
||||
export type { SerializableAudio, Source, AudioVariant } from "./schema";
|
||||
@@ -0,0 +1,46 @@
|
||||
import { z } from "zod";
|
||||
import { defineToolUiContract } from "../shared/contract";
|
||||
import {
|
||||
ToolUIIdSchema,
|
||||
ToolUIReceiptSchema,
|
||||
ToolUIRoleSchema,
|
||||
} from "../shared/schema";
|
||||
|
||||
export const SourceSchema = z.object({
|
||||
label: z.string(),
|
||||
iconUrl: z.url().optional(),
|
||||
url: z.url().optional(),
|
||||
});
|
||||
|
||||
export type Source = z.infer<typeof SourceSchema>;
|
||||
|
||||
export const SerializableAudioSchema = z.object({
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
receipt: ToolUIReceiptSchema.optional(),
|
||||
assetId: z.string(),
|
||||
src: z.url(),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
artwork: z.url().optional(),
|
||||
durationMs: z.number().int().positive().optional(),
|
||||
fileSizeBytes: z.number().int().positive().optional(),
|
||||
createdAt: z.string().datetime().optional(),
|
||||
locale: z.string().optional(),
|
||||
source: SourceSchema.optional(),
|
||||
});
|
||||
|
||||
export type SerializableAudio = z.infer<typeof SerializableAudioSchema>;
|
||||
|
||||
const SerializableAudioSchemaContract = defineToolUiContract(
|
||||
"Audio",
|
||||
SerializableAudioSchema,
|
||||
);
|
||||
|
||||
export const parseSerializableAudio: (input: unknown) => SerializableAudio =
|
||||
SerializableAudioSchemaContract.parse;
|
||||
|
||||
export const safeParseSerializableAudio: (
|
||||
input: unknown,
|
||||
) => SerializableAudio | null = SerializableAudioSchemaContract.safeParse;
|
||||
export type AudioVariant = "full" | "compact";
|
||||
@@ -0,0 +1,19 @@
|
||||
# Chart
|
||||
|
||||
Implementation for the "chart" Tool UI surface.
|
||||
|
||||
## Files
|
||||
|
||||
- public exports: components/tool-ui/chart/index.tsx
|
||||
- serializable schema + parse helpers: components/tool-ui/chart/schema.ts
|
||||
|
||||
## Companion assets
|
||||
|
||||
- Docs page: app/docs/chart/content.mdx
|
||||
- Preset payload: lib/presets/chart.ts
|
||||
|
||||
## Quick check
|
||||
|
||||
Run this after edits:
|
||||
|
||||
pnpm test
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Adapter: UI and utility re-exports for copy-standalone portability.
|
||||
*
|
||||
* When copying this component to another project, update these imports
|
||||
* to match your project's paths:
|
||||
*
|
||||
* cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn")
|
||||
* Chart → shadcn/ui Chart (recharts wrapper)
|
||||
* Card → shadcn/ui Card
|
||||
*/
|
||||
|
||||
export { cn } from "@toolui/lib/utils";
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
type ChartConfig,
|
||||
} from "@toolui/ui/chart";
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@toolui/ui/card";
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useCallback, memo } from "react";
|
||||
import {
|
||||
BarChart,
|
||||
LineChart,
|
||||
Bar,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
|
||||
import {
|
||||
cn,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
type ChartConfig,
|
||||
} from "./_adapter";
|
||||
import type { ChartProps } from "./schema";
|
||||
|
||||
const DEFAULT_COLORS = [
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
"var(--chart-3)",
|
||||
"var(--chart-4)",
|
||||
"var(--chart-5)",
|
||||
];
|
||||
|
||||
export const Chart = memo(function Chart({
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
data,
|
||||
xKey,
|
||||
series,
|
||||
colors,
|
||||
showLegend = false,
|
||||
showGrid = true,
|
||||
className,
|
||||
onDataPointClick,
|
||||
}: ChartProps) {
|
||||
const palette = colors?.length ? colors : DEFAULT_COLORS;
|
||||
|
||||
const seriesColors = useMemo(
|
||||
() =>
|
||||
series.map(
|
||||
(seriesItem, index) =>
|
||||
seriesItem.color ?? palette[index % palette.length],
|
||||
),
|
||||
[series, palette],
|
||||
);
|
||||
|
||||
const chartConfig: ChartConfig = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
series.map((seriesItem, index) => [
|
||||
seriesItem.key,
|
||||
{
|
||||
label: seriesItem.label,
|
||||
color: seriesColors[index],
|
||||
},
|
||||
]),
|
||||
),
|
||||
[series, seriesColors],
|
||||
);
|
||||
|
||||
const handleDataPointClick = useCallback(
|
||||
(
|
||||
seriesKey: string,
|
||||
seriesLabel: string,
|
||||
payload: Record<string, unknown>,
|
||||
index: number,
|
||||
) => {
|
||||
onDataPointClick?.({
|
||||
seriesKey,
|
||||
seriesLabel,
|
||||
xValue: payload[xKey],
|
||||
yValue: payload[seriesKey],
|
||||
index,
|
||||
payload,
|
||||
});
|
||||
},
|
||||
[onDataPointClick, xKey],
|
||||
);
|
||||
|
||||
const ChartComponent = type === "bar" ? BarChart : LineChart;
|
||||
|
||||
const chartContent = (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="min-h-[200px] w-full"
|
||||
data-tool-ui-id={id}
|
||||
>
|
||||
<ChartComponent data={data} accessibilityLayer>
|
||||
{showGrid && <CartesianGrid vertical={false} />}
|
||||
<XAxis
|
||||
dataKey={xKey}
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis tickLine={false} axisLine={false} tickMargin={10} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{showLegend && <ChartLegend content={<ChartLegendContent />} />}
|
||||
|
||||
{type === "bar" &&
|
||||
series.map((s, i) => (
|
||||
<Bar
|
||||
key={s.key}
|
||||
dataKey={s.key}
|
||||
fill={seriesColors[i]}
|
||||
radius={4}
|
||||
onClick={(data) =>
|
||||
handleDataPointClick(s.key, s.label, data.payload, data.index)
|
||||
}
|
||||
cursor={onDataPointClick ? "pointer" : undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
{type === "line" &&
|
||||
series.map((s, i) => (
|
||||
<Line
|
||||
key={s.key}
|
||||
dataKey={s.key}
|
||||
type="monotone"
|
||||
stroke={seriesColors[i]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4, cursor: onDataPointClick ? "pointer" : undefined }}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
cursor: onDataPointClick ? "pointer" : undefined,
|
||||
// Recharts types are incorrect - onClick receives (event, dotData) at runtime
|
||||
onClick: ((
|
||||
_: unknown,
|
||||
dotData: { payload: Record<string, unknown>; index: number },
|
||||
) => {
|
||||
handleDataPointClick(
|
||||
s.key,
|
||||
s.label,
|
||||
dotData.payload,
|
||||
dotData.index,
|
||||
);
|
||||
}) as unknown as React.MouseEventHandler,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ChartComponent>
|
||||
</ChartContainer>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn("w-full min-w-80", className)}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="chart"
|
||||
>
|
||||
{(title || description) && (
|
||||
<CardHeader>
|
||||
{title && <CardTitle className="text-pretty">{title}</CardTitle>}
|
||||
{description && (
|
||||
<CardDescription className="text-pretty">
|
||||
{description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent>{chartContent}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export { Chart } from "./chart";
|
||||
export {
|
||||
type ChartProps,
|
||||
type ChartSeries,
|
||||
type ChartDataPoint,
|
||||
type ChartClientProps,
|
||||
type SerializableChart,
|
||||
} from "./schema";
|
||||
@@ -0,0 +1,121 @@
|
||||
import { z } from "zod";
|
||||
import { defineToolUiContract } from "../shared/contract";
|
||||
import {
|
||||
ToolUIIdSchema,
|
||||
ToolUIReceiptSchema,
|
||||
ToolUIRoleSchema,
|
||||
} from "../shared/schema";
|
||||
|
||||
export const ChartSeriesSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
color: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ChartSeries = z.infer<typeof ChartSeriesSchema>;
|
||||
|
||||
export const ChartPropsSchema = z
|
||||
.object({
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
receipt: ToolUIReceiptSchema.optional(),
|
||||
type: z.enum(["bar", "line"]),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
data: z.array(z.record(z.string(), z.unknown())).min(1),
|
||||
xKey: z.string().min(1),
|
||||
series: z.array(ChartSeriesSchema).min(1),
|
||||
/** Color palette applied to series in order. Individual series.color takes precedence. */
|
||||
colors: z.array(z.string().min(1)).min(1).optional(),
|
||||
showLegend: z.boolean().optional(),
|
||||
showGrid: z.boolean().optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const seenSeriesKeys = new Set<string>();
|
||||
value.series.forEach((series, index) => {
|
||||
if (seenSeriesKeys.has(series.key)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["series", index, "key"],
|
||||
message: `Duplicate series key "${series.key}".`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
seenSeriesKeys.add(series.key);
|
||||
});
|
||||
|
||||
value.data.forEach((row, rowIndex) => {
|
||||
if (!(value.xKey in row)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["data", rowIndex, value.xKey],
|
||||
message: `Missing xKey "${value.xKey}" in data row.`,
|
||||
});
|
||||
} else {
|
||||
const xVal = row[value.xKey];
|
||||
const isValidX = typeof xVal === "string" || typeof xVal === "number";
|
||||
if (!isValidX) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["data", rowIndex, value.xKey],
|
||||
message: `Expected "${value.xKey}" to be a string or number.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
value.series.forEach((series) => {
|
||||
if (!(series.key in row)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["data", rowIndex, series.key],
|
||||
message: `Missing series key "${series.key}" in data row.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const yVal = row[series.key];
|
||||
if (yVal === null) {
|
||||
return;
|
||||
}
|
||||
if (typeof yVal !== "number" || !Number.isFinite(yVal)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["data", rowIndex, series.key],
|
||||
message: `Expected "${series.key}" to be a finite number (or null).`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export type ChartDataPoint = {
|
||||
seriesKey: string;
|
||||
seriesLabel: string;
|
||||
xValue: unknown;
|
||||
yValue: unknown;
|
||||
index: number;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ChartClientProps = {
|
||||
className?: string;
|
||||
onDataPointClick?: (point: ChartDataPoint) => void;
|
||||
};
|
||||
|
||||
export type ChartProps = z.infer<typeof ChartPropsSchema> & ChartClientProps;
|
||||
|
||||
export const SerializableChartSchema = ChartPropsSchema;
|
||||
|
||||
export type SerializableChart = z.infer<typeof SerializableChartSchema>;
|
||||
|
||||
const SerializableChartSchemaContract = defineToolUiContract(
|
||||
"Chart",
|
||||
SerializableChartSchema,
|
||||
);
|
||||
|
||||
export const parseSerializableChart: (input: unknown) => SerializableChart =
|
||||
SerializableChartSchemaContract.parse;
|
||||
|
||||
export const safeParseSerializableChart: (
|
||||
input: unknown,
|
||||
) => SerializableChart | null = SerializableChartSchemaContract.safeParse;
|
||||
@@ -0,0 +1,19 @@
|
||||
# Code Block
|
||||
|
||||
Implementation for the "code-block" Tool UI surface.
|
||||
|
||||
## Files
|
||||
|
||||
- public exports: components/tool-ui/code-block/index.tsx
|
||||
- serializable schema + parse helpers: components/tool-ui/code-block/schema.ts
|
||||
|
||||
## Companion assets
|
||||
|
||||
- Docs page: app/docs/code-block/content.mdx
|
||||
- Preset payload: lib/presets/code-block.ts
|
||||
|
||||
## Quick check
|
||||
|
||||
Run this after edits:
|
||||
|
||||
pnpm test
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Adapter: UI and utility re-exports for copy-standalone portability.
|
||||
*
|
||||
* When copying this component to another project, update these imports
|
||||
* to match your project's paths:
|
||||
*
|
||||
* cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn")
|
||||
* Button → shadcn/ui Button
|
||||
* Collapsible → shadcn/ui Collapsible
|
||||
*/
|
||||
|
||||
export { cn } from "@toolui/lib/utils";
|
||||
export { Button } from "@toolui/ui/button";
|
||||
export { Collapsible, CollapsibleTrigger } from "@toolui/ui/collapsible";
|
||||
@@ -0,0 +1,469 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
createContext,
|
||||
useContext,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
createHighlighter,
|
||||
createJavaScriptRegexEngine,
|
||||
type Highlighter,
|
||||
} from "shiki";
|
||||
import { Copy, Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import pierreDarkTheme from "../shared/pierre-dark-theme.js";
|
||||
import pierreLightTheme from "../shared/pierre-light-theme.js";
|
||||
import type { CodeBlockLineNumbersMode, CodeBlockProps } from "./schema";
|
||||
import { useCopyToClipboard } from "../shared/use-copy-to-clipboard";
|
||||
|
||||
import { Button, cn, Collapsible, CollapsibleTrigger } from "./_adapter";
|
||||
|
||||
const COPY_ID = "codeblock-code";
|
||||
const MAX_HTML_CACHE_ENTRIES = 64;
|
||||
|
||||
let highlighterPromise: Promise<Highlighter> | null = null;
|
||||
|
||||
function getHighlighter(): Promise<Highlighter> {
|
||||
let pending = highlighterPromise;
|
||||
if (!pending) {
|
||||
pending = createHighlighter({
|
||||
themes: [pierreDarkTheme as never, pierreLightTheme as never],
|
||||
langs: [],
|
||||
engine: createJavaScriptRegexEngine(),
|
||||
});
|
||||
highlighterPromise = pending;
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
const htmlCache = new Map<string, string>();
|
||||
|
||||
function getCacheKey(
|
||||
code: string,
|
||||
language: string,
|
||||
theme: string,
|
||||
lineNumbers: CodeBlockLineNumbersMode,
|
||||
highlightLines?: number[],
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
code,
|
||||
language,
|
||||
theme,
|
||||
lineNumbers,
|
||||
highlightLines: highlightLines ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function setCachedHtml(cacheKey: string, html: string): void {
|
||||
if (htmlCache.has(cacheKey)) {
|
||||
htmlCache.set(cacheKey, html);
|
||||
return;
|
||||
}
|
||||
|
||||
if (htmlCache.size >= MAX_HTML_CACHE_ENTRIES) {
|
||||
const oldestKey = htmlCache.keys().next().value;
|
||||
if (typeof oldestKey === "string") {
|
||||
htmlCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
htmlCache.set(cacheKey, html);
|
||||
}
|
||||
|
||||
const LANGUAGE_DISPLAY_NAMES: Record<string, string> = {
|
||||
typescript: "TypeScript",
|
||||
javascript: "JavaScript",
|
||||
python: "Python",
|
||||
tsx: "TSX",
|
||||
jsx: "JSX",
|
||||
json: "JSON",
|
||||
bash: "Bash",
|
||||
shell: "Shell",
|
||||
css: "CSS",
|
||||
html: "HTML",
|
||||
markdown: "Markdown",
|
||||
sql: "SQL",
|
||||
yaml: "YAML",
|
||||
go: "Go",
|
||||
rust: "Rust",
|
||||
text: "Plain Text",
|
||||
};
|
||||
|
||||
function getLanguageDisplayName(lang: string): string {
|
||||
return LANGUAGE_DISPLAY_NAMES[lang.toLowerCase()] || lang.toUpperCase();
|
||||
}
|
||||
|
||||
function getSystemTheme(): "light" | "dark" {
|
||||
if (typeof window === "undefined") return "light";
|
||||
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
function getDocumentTheme(): "light" | "dark" | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const root = document.documentElement;
|
||||
const dataTheme = root.getAttribute("data-theme")?.toLowerCase();
|
||||
if (dataTheme === "dark") return "dark";
|
||||
if (dataTheme === "light") return "light";
|
||||
if (root.classList.contains("dark")) return "dark";
|
||||
if (root.classList.contains("light")) return "light";
|
||||
return null;
|
||||
}
|
||||
|
||||
function useResolvedTheme(): "light" | "dark" {
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
return getDocumentTheme() ?? getSystemTheme();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => setTheme(getDocumentTheme() ?? getSystemTheme());
|
||||
|
||||
const mql = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||
mql?.addEventListener("change", update);
|
||||
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "data-theme"],
|
||||
});
|
||||
|
||||
return () => {
|
||||
mql?.removeEventListener("change", update);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
export type CodeBlockRootProps = CodeBlockProps & {
|
||||
children: ReactNode;
|
||||
expanded?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
};
|
||||
|
||||
type CodeBlockSharedState = {
|
||||
id: string;
|
||||
code: string;
|
||||
language: string;
|
||||
filename?: string;
|
||||
highlightedHtml: string | null;
|
||||
isCopied: boolean;
|
||||
copyCode: () => void;
|
||||
lineCount: number;
|
||||
isCollapsed: boolean;
|
||||
shouldCollapse: boolean;
|
||||
toggleExpanded: () => void;
|
||||
};
|
||||
|
||||
const CodeBlockContext = createContext<CodeBlockSharedState | null>(null);
|
||||
|
||||
function useCodeBlock(): CodeBlockSharedState {
|
||||
const context = useContext(CodeBlockContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"CodeBlock subcomponents must be used within <CodeBlock.Root>.",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function CodeBlockRoot({
|
||||
id,
|
||||
code,
|
||||
language = "text",
|
||||
lineNumbers = "visible",
|
||||
filename,
|
||||
highlightLines,
|
||||
maxCollapsedLines,
|
||||
className,
|
||||
children,
|
||||
expanded: expandedProp,
|
||||
defaultExpanded = false,
|
||||
onExpandedChange,
|
||||
}: CodeBlockRootProps) {
|
||||
const resolvedTheme = useResolvedTheme();
|
||||
const [expandedState, setExpandedState] = useState(defaultExpanded);
|
||||
const { copiedId, copy } = useCopyToClipboard();
|
||||
const isCopied = copiedId === COPY_ID;
|
||||
|
||||
const expanded = expandedProp ?? expandedState;
|
||||
const setExpanded = useCallback(
|
||||
(nextExpanded: boolean) => {
|
||||
if (expandedProp === undefined) {
|
||||
setExpandedState(nextExpanded);
|
||||
}
|
||||
onExpandedChange?.(nextExpanded);
|
||||
},
|
||||
[expandedProp, onExpandedChange],
|
||||
);
|
||||
|
||||
const theme = resolvedTheme === "dark" ? "pierre-dark" : "pierre-light";
|
||||
const cacheKey = getCacheKey(
|
||||
code,
|
||||
language,
|
||||
theme,
|
||||
lineNumbers,
|
||||
highlightLines,
|
||||
);
|
||||
|
||||
const [highlightedHtml, setHighlightedHtml] = useState<string | null>(
|
||||
() => htmlCache.get(cacheKey) ?? null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const cached = htmlCache.get(cacheKey);
|
||||
if (cached) {
|
||||
setHighlightedHtml(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const showLineNumbers = lineNumbers === "visible";
|
||||
|
||||
async function highlight() {
|
||||
if (!code) {
|
||||
if (!cancelled) setHighlightedHtml("");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const highlighter = await getHighlighter();
|
||||
const loadedLangs = highlighter.getLoadedLanguages();
|
||||
|
||||
if (!loadedLangs.includes(language)) {
|
||||
await highlighter.loadLanguage(
|
||||
language as Parameters<Highlighter["loadLanguage"]>[0],
|
||||
);
|
||||
}
|
||||
|
||||
const lineCount = code.split("\n").length;
|
||||
const lineNumberWidth = `${String(lineCount).length + 0.5}ch`;
|
||||
|
||||
const html = highlighter.codeToHtml(code, {
|
||||
lang: language,
|
||||
theme,
|
||||
transformers: [
|
||||
{
|
||||
line(node: any, line: number) {
|
||||
node.properties["data-line"] = line;
|
||||
if (highlightLines?.includes(line)) {
|
||||
const highlightBg =
|
||||
resolvedTheme === "dark"
|
||||
? "rgba(255,255,255,0.1)"
|
||||
: "rgba(0,0,0,0.05)";
|
||||
node.properties.style = `background:${highlightBg};`;
|
||||
}
|
||||
if (showLineNumbers) {
|
||||
node.children.unshift({
|
||||
type: "element",
|
||||
tagName: "span",
|
||||
properties: {
|
||||
style: `display:inline-block;width:${lineNumberWidth};text-align:right;margin-right:1.5em;user-select:none;opacity:0.5;`,
|
||||
"aria-hidden": "true",
|
||||
},
|
||||
children: [{ type: "text", value: String(line) }],
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!cancelled) {
|
||||
setCachedHtml(cacheKey, html);
|
||||
setHighlightedHtml(html);
|
||||
}
|
||||
} catch {
|
||||
const escaped = code
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
if (!cancelled) {
|
||||
setHighlightedHtml(`<pre><code>${escaped}</code></pre>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
void highlight();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
cacheKey,
|
||||
code,
|
||||
language,
|
||||
lineNumbers,
|
||||
theme,
|
||||
highlightLines,
|
||||
resolvedTheme,
|
||||
]);
|
||||
|
||||
const lineCount = code.split("\n").length;
|
||||
const shouldCollapse = !!maxCollapsedLines && lineCount > maxCollapsedLines;
|
||||
const isCollapsed = shouldCollapse && !expanded;
|
||||
|
||||
const copyCode = useCallback(() => {
|
||||
void copy(code, COPY_ID);
|
||||
}, [code, copy]);
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setExpanded(!expanded);
|
||||
}, [expanded, setExpanded]);
|
||||
|
||||
const state: CodeBlockSharedState = {
|
||||
id,
|
||||
code,
|
||||
language,
|
||||
filename,
|
||||
highlightedHtml,
|
||||
isCopied,
|
||||
copyCode,
|
||||
lineCount,
|
||||
shouldCollapse,
|
||||
isCollapsed,
|
||||
toggleExpanded,
|
||||
};
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={state}>
|
||||
<div
|
||||
className={cn(
|
||||
"@container flex w-full min-w-80 flex-col gap-3",
|
||||
className,
|
||||
)}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="code-block"
|
||||
>
|
||||
<div className="border-border bg-card overflow-hidden rounded-lg border shadow-xs">
|
||||
<Collapsible open={!isCollapsed}>{children}</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
</CodeBlockContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export type CodeBlockSectionProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function CodeBlockHeader({ className }: CodeBlockSectionProps) {
|
||||
const { language, filename, isCopied, copyCode } = useCodeBlock();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card flex items-center justify-between border-b px-4 py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{getLanguageDisplayName(language)}
|
||||
</span>
|
||||
{filename && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">•</span>
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{filename}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={copyCode}
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label={isCopied ? "Copied" : "Copy code"}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-4 w-4 text-green-700 dark:text-green-400" />
|
||||
) : (
|
||||
<Copy className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlockContent({ className }: CodeBlockSectionProps) {
|
||||
const { highlightedHtml, isCollapsed } = useCodeBlock();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto overflow-y-clip text-[13px] leading-[1.4] [&_pre]:bg-transparent [&_pre]:py-4",
|
||||
isCollapsed && "max-h-[200px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{highlightedHtml && (
|
||||
<div dangerouslySetInnerHTML={{ __html: highlightedHtml }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlockCollapseToggle({ className }: CodeBlockSectionProps) {
|
||||
const { shouldCollapse, isCollapsed, toggleExpanded, lineCount } =
|
||||
useCodeBlock();
|
||||
|
||||
if (!shouldCollapse) return null;
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={toggleExpanded}
|
||||
className={cn(
|
||||
"text-muted-foreground w-full rounded-none border-t font-normal",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<>
|
||||
<ChevronDown className="mr-1 size-4" />
|
||||
Show all {lineCount} lines
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronUp className="mr-2 h-4 w-4" />
|
||||
Collapse
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export type CodeBlockComposedProps = Omit<CodeBlockRootProps, "children">;
|
||||
|
||||
function CodeBlockComposed(props: CodeBlockComposedProps) {
|
||||
return (
|
||||
<CodeBlockRoot {...props}>
|
||||
<CodeBlockHeader />
|
||||
<CodeBlockContent />
|
||||
<CodeBlockCollapseToggle />
|
||||
</CodeBlockRoot>
|
||||
);
|
||||
}
|
||||
|
||||
type CodeBlockComponent = typeof CodeBlockComposed & {
|
||||
Root: typeof CodeBlockRoot;
|
||||
Header: typeof CodeBlockHeader;
|
||||
Content: typeof CodeBlockContent;
|
||||
CollapseToggle: typeof CodeBlockCollapseToggle;
|
||||
};
|
||||
|
||||
export const CodeBlock = Object.assign(CodeBlockComposed, {
|
||||
Root: CodeBlockRoot,
|
||||
Header: CodeBlockHeader,
|
||||
Content: CodeBlockContent,
|
||||
CollapseToggle: CodeBlockCollapseToggle,
|
||||
}) as CodeBlockComponent;
|
||||
@@ -0,0 +1,11 @@
|
||||
export { CodeBlock } from "./code-block";
|
||||
export type {
|
||||
CodeBlockRootProps,
|
||||
CodeBlockComposedProps,
|
||||
CodeBlockSectionProps,
|
||||
} from "./code-block";
|
||||
export type {
|
||||
CodeBlockProps,
|
||||
CodeBlockLineNumbersMode,
|
||||
SerializableCodeBlock,
|
||||
} from "./schema";
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from "zod";
|
||||
import { defineToolUiContract } from "../shared/contract";
|
||||
import {
|
||||
ToolUIIdSchema,
|
||||
ToolUIReceiptSchema,
|
||||
ToolUIRoleSchema,
|
||||
} from "../shared/schema";
|
||||
|
||||
export const CodeBlockPropsSchema = z.object({
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
receipt: ToolUIReceiptSchema.optional(),
|
||||
code: z.string(),
|
||||
language: z.string().trim().min(1).default("text"),
|
||||
lineNumbers: z.enum(["visible", "hidden"]).default("visible"),
|
||||
filename: z.string().optional(),
|
||||
highlightLines: z.array(z.number().int().positive()).optional(),
|
||||
maxCollapsedLines: z.number().min(1).optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CodeBlockProps = z.infer<typeof CodeBlockPropsSchema>;
|
||||
export type CodeBlockLineNumbersMode = CodeBlockProps["lineNumbers"];
|
||||
|
||||
export const SerializableCodeBlockSchema = CodeBlockPropsSchema.omit({
|
||||
className: true,
|
||||
});
|
||||
|
||||
export type SerializableCodeBlock = z.infer<typeof SerializableCodeBlockSchema>;
|
||||
|
||||
const SerializableCodeBlockSchemaContract = defineToolUiContract(
|
||||
"CodeBlock",
|
||||
SerializableCodeBlockSchema,
|
||||
);
|
||||
|
||||
export const parseSerializableCodeBlock: (
|
||||
input: unknown,
|
||||
) => SerializableCodeBlock = SerializableCodeBlockSchemaContract.parse;
|
||||
|
||||
export const safeParseSerializableCodeBlock: (
|
||||
input: unknown,
|
||||
) => SerializableCodeBlock | null =
|
||||
SerializableCodeBlockSchemaContract.safeParse;
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Adapter: UI and utility re-exports for copy-standalone portability.
|
||||
*
|
||||
* When copying this component to another project, update these imports
|
||||
* to match your project's paths:
|
||||
*
|
||||
* cn -> Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn")
|
||||
* Button -> shadcn/ui Button
|
||||
* Collapsible -> shadcn/ui Collapsible
|
||||
*/
|
||||
|
||||
export { cn } from "@toolui/lib/utils";
|
||||
export { Button } from "@toolui/ui/button";
|
||||
export { Collapsible, CollapsibleTrigger } from "@toolui/ui/collapsible";
|
||||
@@ -0,0 +1,463 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
createContext,
|
||||
useContext,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
FileDiff as PierreFileDiff,
|
||||
PatchDiff as PierrePatchDiff,
|
||||
} from "@pierre/diffs/react";
|
||||
import { parseDiffFromFile, RegisteredCustomThemes } from "@pierre/diffs";
|
||||
import type { FileDiffMetadata, ThemesType } from "@pierre/diffs";
|
||||
import { Copy, Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { CodeDiffProps } from "./schema";
|
||||
import { useCopyToClipboard } from "../shared/use-copy-to-clipboard";
|
||||
import { Button, cn, Collapsible, CollapsibleTrigger } from "./_adapter";
|
||||
|
||||
/*
|
||||
* Pierre's shared_highlighter registers custom themes with dynamic imports
|
||||
* (`import("../themes/pierre-dark.js")`) that fail under Turbopack because the
|
||||
* package `exports` field doesn't include those subpaths. We override the
|
||||
* RegisteredCustomThemes map entries with loaders that point to local vendored
|
||||
* theme files in `components/tool-ui/shared`, which Turbopack can resolve.
|
||||
*/
|
||||
RegisteredCustomThemes.set("pierre-dark", () =>
|
||||
import("../shared/pierre-dark-theme.js").then((m) => m.default as never),
|
||||
);
|
||||
RegisteredCustomThemes.set("pierre-light", () =>
|
||||
import("../shared/pierre-light-theme.js").then((m) => m.default as never),
|
||||
);
|
||||
|
||||
const COPY_ID = "codediff-code";
|
||||
|
||||
/* ── Theme detection (mirrors CodeBlock) ────────────────────────── */
|
||||
|
||||
function getSystemTheme(): "light" | "dark" {
|
||||
if (typeof window === "undefined") return "light";
|
||||
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
function getDocumentTheme(): "light" | "dark" | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const root = document.documentElement;
|
||||
const dataTheme = root.getAttribute("data-theme")?.toLowerCase();
|
||||
if (dataTheme === "dark") return "dark";
|
||||
if (dataTheme === "light") return "light";
|
||||
if (root.classList.contains("dark")) return "dark";
|
||||
if (root.classList.contains("light")) return "light";
|
||||
return null;
|
||||
}
|
||||
|
||||
function useResolvedTheme(): "light" | "dark" {
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
return getDocumentTheme() ?? getSystemTheme();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => setTheme(getDocumentTheme() ?? getSystemTheme());
|
||||
|
||||
const mql = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||
mql?.addEventListener("change", update);
|
||||
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "data-theme"],
|
||||
});
|
||||
|
||||
return () => {
|
||||
mql?.removeEventListener("change", update);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
/* ── Language display names (mirrors CodeBlock) ─────────────────── */
|
||||
|
||||
const LANGUAGE_DISPLAY_NAMES: Record<string, string> = {
|
||||
typescript: "TypeScript",
|
||||
javascript: "JavaScript",
|
||||
python: "Python",
|
||||
tsx: "TSX",
|
||||
jsx: "JSX",
|
||||
json: "JSON",
|
||||
bash: "Bash",
|
||||
shell: "Shell",
|
||||
css: "CSS",
|
||||
html: "HTML",
|
||||
markdown: "Markdown",
|
||||
sql: "SQL",
|
||||
yaml: "YAML",
|
||||
go: "Go",
|
||||
rust: "Rust",
|
||||
text: "Plain Text",
|
||||
};
|
||||
|
||||
function getLanguageDisplayName(lang: string): string {
|
||||
return LANGUAGE_DISPLAY_NAMES[lang.toLowerCase()] || lang.toUpperCase();
|
||||
}
|
||||
|
||||
/* ── Shared context ─────────────────────────────────────────────── */
|
||||
|
||||
type CodeDiffSharedState = {
|
||||
id: string;
|
||||
isPatchMode: boolean;
|
||||
language: string;
|
||||
lineNumbers: "visible" | "hidden";
|
||||
filename?: string;
|
||||
diffStyle: "unified" | "split";
|
||||
copyableCode: string;
|
||||
isCopied: boolean;
|
||||
copyCode: () => void;
|
||||
isCollapsed: boolean;
|
||||
shouldCollapse: boolean;
|
||||
toggleExpanded: () => void;
|
||||
resolvedTheme: "light" | "dark";
|
||||
pierreThemes: ThemesType;
|
||||
fileDiffMetadata: FileDiffMetadata | null;
|
||||
patch: string | null;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
};
|
||||
|
||||
const CodeDiffContext = createContext<CodeDiffSharedState | null>(null);
|
||||
|
||||
function useCodeDiff(): CodeDiffSharedState {
|
||||
const context = useContext(CodeDiffContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"CodeDiff subcomponents must be used within <CodeDiff.Root>.",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/* ── Subcomponents ──────────────────────────────────────────────── */
|
||||
|
||||
export type CodeDiffRootProps = CodeDiffProps & {
|
||||
children: ReactNode;
|
||||
expanded?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
};
|
||||
|
||||
function CodeDiffRoot({
|
||||
id,
|
||||
oldCode,
|
||||
newCode,
|
||||
patch,
|
||||
language = "text",
|
||||
filename,
|
||||
lineNumbers = "visible",
|
||||
diffStyle = "unified",
|
||||
maxCollapsedLines,
|
||||
className,
|
||||
children,
|
||||
expanded: expandedProp,
|
||||
defaultExpanded = false,
|
||||
onExpandedChange,
|
||||
}: CodeDiffRootProps) {
|
||||
const resolvedTheme = useResolvedTheme();
|
||||
const [expandedState, setExpandedState] = useState(defaultExpanded);
|
||||
const { copiedId, copy } = useCopyToClipboard();
|
||||
const isCopied = copiedId === COPY_ID;
|
||||
|
||||
const expanded = expandedProp ?? expandedState;
|
||||
const setExpanded = useCallback(
|
||||
(nextExpanded: boolean) => {
|
||||
if (expandedProp === undefined) {
|
||||
setExpandedState(nextExpanded);
|
||||
}
|
||||
onExpandedChange?.(nextExpanded);
|
||||
},
|
||||
[expandedProp, onExpandedChange],
|
||||
);
|
||||
|
||||
const pierreThemes: ThemesType = {
|
||||
dark: "pierre-dark",
|
||||
light: "pierre-light",
|
||||
};
|
||||
|
||||
// Auto-detect mode: if `patch` is provided, use patch mode; otherwise files mode
|
||||
const isPatchMode = !!patch;
|
||||
|
||||
const fileDiffMetadata = useMemo(() => {
|
||||
if (isPatchMode) return null;
|
||||
return parseDiffFromFile(
|
||||
{
|
||||
name: filename ?? "file",
|
||||
contents: oldCode ?? "",
|
||||
lang: language as never,
|
||||
},
|
||||
{
|
||||
name: filename ?? "file",
|
||||
contents: newCode ?? "",
|
||||
lang: language as never,
|
||||
},
|
||||
);
|
||||
}, [isPatchMode, oldCode, newCode, filename, language]);
|
||||
|
||||
const copyableCode = isPatchMode ? (patch ?? "") : (newCode ?? oldCode ?? "");
|
||||
|
||||
const lineCount = useMemo(() => {
|
||||
if (isPatchMode) {
|
||||
return (patch ?? "").split("\n").length;
|
||||
}
|
||||
if (fileDiffMetadata) {
|
||||
return fileDiffMetadata.unifiedLineCount;
|
||||
}
|
||||
return 0;
|
||||
}, [isPatchMode, patch, fileDiffMetadata]);
|
||||
|
||||
const { additions, deletions } = useMemo(() => {
|
||||
if (!isPatchMode && fileDiffMetadata) {
|
||||
let add = 0;
|
||||
let del = 0;
|
||||
for (const hunk of fileDiffMetadata.hunks) {
|
||||
add += hunk.additionLines;
|
||||
del += hunk.deletionLines;
|
||||
}
|
||||
return { additions: add, deletions: del };
|
||||
}
|
||||
if (isPatchMode && patch) {
|
||||
let add = 0;
|
||||
let del = 0;
|
||||
for (const line of patch.split("\n")) {
|
||||
if (line.startsWith("+") && !line.startsWith("+++ ")) add++;
|
||||
else if (line.startsWith("-") && !line.startsWith("--- ")) del++;
|
||||
}
|
||||
return { additions: add, deletions: del };
|
||||
}
|
||||
return { additions: 0, deletions: 0 };
|
||||
}, [isPatchMode, fileDiffMetadata, patch]);
|
||||
|
||||
const shouldCollapse = !!maxCollapsedLines && lineCount > maxCollapsedLines;
|
||||
const isCollapsed = shouldCollapse && !expanded;
|
||||
|
||||
const copyCode = useCallback(() => {
|
||||
void copy(copyableCode, COPY_ID);
|
||||
}, [copyableCode, copy]);
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setExpanded(!expanded);
|
||||
}, [expanded, setExpanded]);
|
||||
|
||||
const state: CodeDiffSharedState = {
|
||||
id,
|
||||
isPatchMode,
|
||||
language,
|
||||
lineNumbers,
|
||||
filename,
|
||||
diffStyle,
|
||||
copyableCode,
|
||||
isCopied,
|
||||
copyCode,
|
||||
isCollapsed,
|
||||
shouldCollapse,
|
||||
toggleExpanded,
|
||||
resolvedTheme,
|
||||
pierreThemes,
|
||||
fileDiffMetadata,
|
||||
patch: isPatchMode ? (patch ?? null) : null,
|
||||
additions,
|
||||
deletions,
|
||||
};
|
||||
|
||||
return (
|
||||
<CodeDiffContext.Provider value={state}>
|
||||
<div
|
||||
className={cn(
|
||||
"@container flex w-full min-w-80 flex-col gap-3",
|
||||
className,
|
||||
)}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="code-diff"
|
||||
>
|
||||
<div className="border-border bg-card overflow-hidden rounded-lg border shadow-xs">
|
||||
<Collapsible open={!isCollapsed}>{children}</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
</CodeDiffContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export type CodeDiffSectionProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function CodeDiffHeader({ className }: CodeDiffSectionProps) {
|
||||
const { language, filename, isCopied, copyCode, additions, deletions } =
|
||||
useCodeDiff();
|
||||
const hasChanges = additions > 0 || deletions > 0;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card flex items-center justify-between gap-2 border-b px-4 py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{getLanguageDisplayName(language)}
|
||||
</span>
|
||||
{filename && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">•</span>
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{filename}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{hasChanges && (
|
||||
<span className="ml-auto text-xs font-mono tabular-nums">
|
||||
{additions > 0 && (
|
||||
<span style={{ color: "#00cab1" }}>+{additions}</span>
|
||||
)}
|
||||
{additions > 0 && deletions > 0 && " "}
|
||||
{deletions > 0 && (
|
||||
<span style={{ color: "#ff2e3f" }}>-{deletions}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={copyCode}
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label={isCopied ? "Copied" : "Copy code"}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-4 w-4 text-green-700 dark:text-green-400" />
|
||||
) : (
|
||||
<Copy className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeDiffContent({ className }: CodeDiffSectionProps) {
|
||||
const {
|
||||
isPatchMode,
|
||||
diffStyle,
|
||||
lineNumbers,
|
||||
isCollapsed,
|
||||
resolvedTheme,
|
||||
pierreThemes,
|
||||
fileDiffMetadata,
|
||||
patch,
|
||||
} = useCodeDiff();
|
||||
|
||||
const disableLineNumbers = lineNumbers === "hidden";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto overflow-y-clip text-sm",
|
||||
isCollapsed && "max-h-[200px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!isPatchMode && fileDiffMetadata && (
|
||||
<PierreFileDiff
|
||||
fileDiff={fileDiffMetadata}
|
||||
options={{
|
||||
theme: pierreThemes,
|
||||
themeType: resolvedTheme,
|
||||
diffStyle,
|
||||
disableFileHeader: true,
|
||||
disableLineNumbers,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isPatchMode && patch && (
|
||||
<PierrePatchDiff
|
||||
patch={patch}
|
||||
options={{
|
||||
theme: pierreThemes,
|
||||
themeType: resolvedTheme,
|
||||
diffStyle,
|
||||
disableFileHeader: true,
|
||||
disableLineNumbers,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeDiffCollapseToggle({ className }: CodeDiffSectionProps) {
|
||||
const { shouldCollapse, isCollapsed, toggleExpanded } = useCodeDiff();
|
||||
|
||||
if (!shouldCollapse) return null;
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={toggleExpanded}
|
||||
className={cn(
|
||||
"text-muted-foreground w-full rounded-none border-t font-normal",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<>
|
||||
<ChevronDown className="mr-1 size-4" />
|
||||
Show full diff
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronUp className="mr-2 h-4 w-4" />
|
||||
Collapse
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Composed preset (callable as a flat component) ─────────────── */
|
||||
|
||||
export type CodeDiffComposedProps = Omit<CodeDiffRootProps, "children">;
|
||||
|
||||
function CodeDiffComposed(props: CodeDiffComposedProps) {
|
||||
return (
|
||||
<CodeDiffRoot {...props}>
|
||||
<CodeDiffHeader />
|
||||
<CodeDiffContent />
|
||||
<CodeDiffCollapseToggle />
|
||||
</CodeDiffRoot>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Compound export: CodeDiff is callable AND has subcomponents ── */
|
||||
|
||||
type CodeDiffComponent = typeof CodeDiffComposed & {
|
||||
Root: typeof CodeDiffRoot;
|
||||
Header: typeof CodeDiffHeader;
|
||||
Content: typeof CodeDiffContent;
|
||||
CollapseToggle: typeof CodeDiffCollapseToggle;
|
||||
};
|
||||
|
||||
export const CodeDiff = Object.assign(CodeDiffComposed, {
|
||||
Root: CodeDiffRoot,
|
||||
Header: CodeDiffHeader,
|
||||
Content: CodeDiffContent,
|
||||
CollapseToggle: CodeDiffCollapseToggle,
|
||||
}) as CodeDiffComponent;
|
||||
@@ -0,0 +1,7 @@
|
||||
export { CodeDiff } from "./code-diff";
|
||||
export type {
|
||||
CodeDiffRootProps,
|
||||
CodeDiffComposedProps,
|
||||
CodeDiffSectionProps,
|
||||
} from "./code-diff";
|
||||
export type { CodeDiffProps, SerializableCodeDiff } from "./schema";
|
||||
@@ -0,0 +1,71 @@
|
||||
import { z } from "zod";
|
||||
import { defineToolUiContract } from "../shared/contract";
|
||||
import {
|
||||
ToolUIIdSchema,
|
||||
ToolUIReceiptSchema,
|
||||
ToolUIRoleSchema,
|
||||
} from "../shared/schema";
|
||||
|
||||
const CodeDiffPropsSchemaBase = z.object({
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
receipt: ToolUIReceiptSchema.optional(),
|
||||
oldCode: z.string().optional(),
|
||||
newCode: z.string().optional(),
|
||||
patch: z.string().optional(),
|
||||
language: z.string().trim().min(1).default("text"),
|
||||
filename: z.string().optional(),
|
||||
lineNumbers: z.enum(["visible", "hidden"]).default("visible"),
|
||||
diffStyle: z.enum(["unified", "split"]).default("unified"),
|
||||
maxCollapsedLines: z.number().min(1).optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
function validateCodeDiffInputMode(
|
||||
data: { patch?: string; oldCode?: string; newCode?: string },
|
||||
ctx: z.RefinementCtx,
|
||||
) {
|
||||
const hasPatch = !!data.patch;
|
||||
const hasFiles = !!data.oldCode || !!data.newCode;
|
||||
|
||||
if (!hasPatch && !hasFiles) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Provide either a patch string or at least one of oldCode/newCode",
|
||||
});
|
||||
}
|
||||
|
||||
if (hasPatch && hasFiles) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Cannot mix patch mode with oldCode/newCode — use one or the other",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const CodeDiffPropsSchema = CodeDiffPropsSchemaBase.superRefine(
|
||||
validateCodeDiffInputMode,
|
||||
);
|
||||
|
||||
export type CodeDiffProps = z.infer<typeof CodeDiffPropsSchema>;
|
||||
|
||||
export const SerializableCodeDiffSchema = CodeDiffPropsSchemaBase.omit({
|
||||
className: true,
|
||||
}).superRefine(validateCodeDiffInputMode);
|
||||
|
||||
export type SerializableCodeDiff = z.infer<typeof SerializableCodeDiffSchema>;
|
||||
|
||||
const SerializableCodeDiffSchemaContract = defineToolUiContract(
|
||||
"CodeDiff",
|
||||
SerializableCodeDiffSchema,
|
||||
);
|
||||
|
||||
export const parseSerializableCodeDiff: (
|
||||
input: unknown,
|
||||
) => SerializableCodeDiff = SerializableCodeDiffSchemaContract.parse;
|
||||
|
||||
export const safeParseSerializableCodeDiff: (
|
||||
input: unknown,
|
||||
) => SerializableCodeDiff | null = SerializableCodeDiffSchemaContract.safeParse;
|
||||
@@ -0,0 +1,24 @@
|
||||
# Geo Map
|
||||
|
||||
Implementation for the "geo-map" Tool UI surface.
|
||||
|
||||
## Files
|
||||
|
||||
- public exports: components/tool-ui/geo-map/index.tsx
|
||||
- serializable schema + parse helpers: components/tool-ui/geo-map/schema.ts
|
||||
- public facade component: components/tool-ui/geo-map/geo-map.tsx
|
||||
- internal Leaflet engine: components/tool-ui/geo-map/geo-map-engine.tsx
|
||||
- colocated Leaflet shell theme styles: components/tool-ui/geo-map/geo-map-theme.module.css
|
||||
- icon construction helpers: components/tool-ui/geo-map/geo-map-icons.ts
|
||||
- popup/tooltip overlay renderer: components/tool-ui/geo-map/geo-map-overlays.tsx
|
||||
|
||||
## Companion assets
|
||||
|
||||
- Docs page: app/docs/geo-map/content.mdx
|
||||
- Preset payload: lib/presets/geo-map.ts
|
||||
|
||||
## Quick check
|
||||
|
||||
Run this after edits:
|
||||
|
||||
pnpm test
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Adapter: UI and utility re-exports for copy-standalone portability.
|
||||
*
|
||||
* When copying this component to another project, update these imports
|
||||
* to match your project's paths:
|
||||
*
|
||||
* cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn")
|
||||
* Leaflet → map primitives from react-leaflet
|
||||
*/
|
||||
|
||||
export { cn } from "@toolui/lib/utils";
|
||||
export {
|
||||
CircleMarker,
|
||||
MapContainer,
|
||||
Marker,
|
||||
Polyline,
|
||||
Popup,
|
||||
TileLayer,
|
||||
Tooltip,
|
||||
ZoomControl,
|
||||
useMap,
|
||||
useMapEvents,
|
||||
} from "react-leaflet";
|
||||
@@ -0,0 +1,756 @@
|
||||
"use client";
|
||||
|
||||
import type { Map as LeafletMap } from "leaflet";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Supercluster from "supercluster";
|
||||
import {
|
||||
CircleMarker,
|
||||
MapContainer,
|
||||
Marker,
|
||||
Polyline,
|
||||
TileLayer,
|
||||
ZoomControl,
|
||||
useMap,
|
||||
useMapEvents,
|
||||
} from "./_adapter";
|
||||
import { createClusterIcon, resolveMarkerIcon } from "./geo-map-icons";
|
||||
import { GeoMapOverlays } from "./geo-map-overlays";
|
||||
import type {
|
||||
GeoMapClustering,
|
||||
GeoMapFitTarget,
|
||||
GeoMapMarker,
|
||||
GeoMapRoute,
|
||||
GeoMapViewport,
|
||||
} from "./schema";
|
||||
|
||||
const TILE_ATTRIBUTION =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>';
|
||||
const ROUTE_DEFAULT_COLOR = "var(--primary)";
|
||||
const ROUTE_DEFAULT_WEIGHT = 3;
|
||||
const ROUTE_DEFAULT_OPACITY = 0.85;
|
||||
const EMPTY_ROUTES: GeoMapRoute[] = [];
|
||||
|
||||
const CLUSTER_RADIUS_DEFAULT = 60;
|
||||
const CLUSTER_MAX_ZOOM_DEFAULT = 16;
|
||||
const CLUSTER_MIN_POINTS_DEFAULT = 2;
|
||||
|
||||
const DEFAULT_CENTER: [number, number] = [20, 0];
|
||||
export const DEFAULT_VIEW_ZOOM = 2;
|
||||
const SINGLE_LOCATION_ZOOM = 13;
|
||||
const DEFAULT_VIEWPORT_PADDING = 32;
|
||||
|
||||
type LeafletRuntime = Pick<
|
||||
typeof import("leaflet"),
|
||||
"divIcon" | "latLngBounds"
|
||||
>;
|
||||
|
||||
export type GeoMapBbox = [
|
||||
west: number,
|
||||
south: number,
|
||||
east: number,
|
||||
north: number,
|
||||
];
|
||||
export type GeoMapLatLng = [lat: number, lng: number];
|
||||
|
||||
export type GeoMapClusterProperties = {
|
||||
cluster?: boolean;
|
||||
cluster_id?: number;
|
||||
point_count?: number;
|
||||
markerId?: string;
|
||||
};
|
||||
|
||||
export type GeoMapClusterFeature = GeoJSON.Feature<
|
||||
GeoJSON.Point,
|
||||
GeoMapClusterProperties
|
||||
>;
|
||||
|
||||
type MarkerClusterPointProperties = GeoMapClusterProperties & {
|
||||
markerId?: string;
|
||||
marker?: GeoMapMarker;
|
||||
};
|
||||
|
||||
type MapViewportState = {
|
||||
bbox: GeoMapBbox;
|
||||
zoom: number;
|
||||
};
|
||||
|
||||
function roundCoordinate(value: number): number {
|
||||
return Math.round(value * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
function normalizeViewportState(state: MapViewportState): MapViewportState {
|
||||
return {
|
||||
bbox: [
|
||||
roundCoordinate(state.bbox[0]),
|
||||
roundCoordinate(state.bbox[1]),
|
||||
roundCoordinate(state.bbox[2]),
|
||||
roundCoordinate(state.bbox[3]),
|
||||
],
|
||||
zoom: state.zoom,
|
||||
};
|
||||
}
|
||||
|
||||
function areViewportStatesEqual(
|
||||
a: MapViewportState | null,
|
||||
b: MapViewportState,
|
||||
): boolean {
|
||||
if (!a) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
a.zoom === b.zoom &&
|
||||
a.bbox[0] === b.bbox[0] &&
|
||||
a.bbox[1] === b.bbox[1] &&
|
||||
a.bbox[2] === b.bbox[2] &&
|
||||
a.bbox[3] === b.bbox[3]
|
||||
);
|
||||
}
|
||||
|
||||
function serializeFitPoints(points: [number, number][]): string {
|
||||
return points
|
||||
.map(([lat, lng]) => `${roundCoordinate(lat)},${roundCoordinate(lng)}`)
|
||||
.join("|");
|
||||
}
|
||||
|
||||
function readViewportState(map: LeafletMap): MapViewportState {
|
||||
const bounds = map.getBounds();
|
||||
return normalizeViewportState({
|
||||
bbox: [
|
||||
bounds.getWest(),
|
||||
bounds.getSouth(),
|
||||
bounds.getEast(),
|
||||
bounds.getNorth(),
|
||||
],
|
||||
zoom: Math.round(map.getZoom()),
|
||||
});
|
||||
}
|
||||
|
||||
export function collectFitPoints(
|
||||
markers: GeoMapMarker[],
|
||||
routes: GeoMapRoute[],
|
||||
target: GeoMapFitTarget,
|
||||
): GeoMapLatLng[] {
|
||||
const markerPoints =
|
||||
target === "markers" || target === "all"
|
||||
? markers.map((marker) => [marker.lat, marker.lng] as GeoMapLatLng)
|
||||
: [];
|
||||
|
||||
const routePoints =
|
||||
target === "routes" || target === "all"
|
||||
? routes.flatMap((route) =>
|
||||
route.points.map((point) => [point.lat, point.lng] as GeoMapLatLng),
|
||||
)
|
||||
: [];
|
||||
|
||||
return [...markerPoints, ...routePoints];
|
||||
}
|
||||
|
||||
export function resolveFitPointsWithFallback(
|
||||
markers: GeoMapMarker[],
|
||||
routes: GeoMapRoute[],
|
||||
target: GeoMapFitTarget,
|
||||
): GeoMapLatLng[] {
|
||||
const selected = collectFitPoints(markers, routes, target);
|
||||
if (selected.length > 0) {
|
||||
return selected;
|
||||
}
|
||||
|
||||
if (target !== "markers") {
|
||||
return collectFitPoints(markers, routes, "markers");
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function splitDatelineBbox(bbox: GeoMapBbox): GeoMapBbox[] {
|
||||
const [west, south, east, north] = bbox;
|
||||
|
||||
if (west <= east) {
|
||||
return [bbox];
|
||||
}
|
||||
|
||||
return [
|
||||
[west, south, 180, north],
|
||||
[-180, south, east, north],
|
||||
];
|
||||
}
|
||||
|
||||
function getClusterFeatureKey(feature: GeoMapClusterFeature): string {
|
||||
const properties = feature.properties ?? {};
|
||||
|
||||
if (properties.cluster && typeof properties.cluster_id === "number") {
|
||||
return `cluster:${properties.cluster_id}`;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof properties.markerId === "string" &&
|
||||
properties.markerId.length > 0
|
||||
) {
|
||||
return `marker:${properties.markerId}`;
|
||||
}
|
||||
|
||||
if (feature.id !== undefined && feature.id !== null) {
|
||||
return `id:${String(feature.id)}`;
|
||||
}
|
||||
|
||||
const [lng, lat] = feature.geometry.coordinates;
|
||||
return `point:${lat}:${lng}`;
|
||||
}
|
||||
|
||||
function dedupeClusterFeatures(
|
||||
features: GeoMapClusterFeature[],
|
||||
): GeoMapClusterFeature[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: GeoMapClusterFeature[] = [];
|
||||
|
||||
features.forEach((feature) => {
|
||||
const key = getClusterFeatureKey(feature);
|
||||
if (seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
deduped.push(feature);
|
||||
});
|
||||
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export function getClustersForDatelineAwareBbox(
|
||||
bbox: GeoMapBbox,
|
||||
zoom: number,
|
||||
getClustersForBbox: (
|
||||
candidateBbox: GeoMapBbox,
|
||||
zoom: number,
|
||||
) => GeoMapClusterFeature[],
|
||||
): GeoMapClusterFeature[] {
|
||||
const queried = splitDatelineBbox(bbox).flatMap((candidateBbox) =>
|
||||
getClustersForBbox(candidateBbox, zoom),
|
||||
);
|
||||
|
||||
return dedupeClusterFeatures(queried);
|
||||
}
|
||||
|
||||
export function toSafeExpansionZoom(
|
||||
zoom: number,
|
||||
options?: { minZoom?: number; maxZoom?: number; fallback?: number },
|
||||
): number {
|
||||
const minZoom = options?.minZoom ?? 1;
|
||||
const maxZoom = options?.maxZoom ?? 22;
|
||||
const fallback = options?.fallback ?? 2;
|
||||
|
||||
if (!Number.isFinite(zoom)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.min(maxZoom, Math.max(minZoom, Math.round(zoom)));
|
||||
}
|
||||
|
||||
function resolveInitialView(
|
||||
markers: GeoMapMarker[],
|
||||
routes: GeoMapRoute[],
|
||||
viewport: GeoMapViewport | undefined,
|
||||
): { center: [number, number]; zoom: number } {
|
||||
if (viewport?.mode === "center") {
|
||||
return {
|
||||
center: [viewport.center.lat, viewport.center.lng],
|
||||
zoom: viewport.zoom,
|
||||
};
|
||||
}
|
||||
|
||||
const fitTarget = viewport?.target ?? "all";
|
||||
const fitPoints = resolveFitPointsWithFallback(markers, routes, fitTarget);
|
||||
|
||||
if (fitPoints.length === 1) {
|
||||
return {
|
||||
center: [fitPoints[0][0], fitPoints[0][1]],
|
||||
zoom: viewport?.maxZoom
|
||||
? Math.min(SINGLE_LOCATION_ZOOM, viewport.maxZoom)
|
||||
: SINGLE_LOCATION_ZOOM,
|
||||
};
|
||||
}
|
||||
|
||||
return { center: DEFAULT_CENTER, zoom: DEFAULT_VIEW_ZOOM };
|
||||
}
|
||||
|
||||
function ViewportController({
|
||||
markers,
|
||||
routes,
|
||||
viewport,
|
||||
leafletRuntime,
|
||||
}: {
|
||||
markers: GeoMapMarker[];
|
||||
routes: GeoMapRoute[];
|
||||
viewport: GeoMapViewport | undefined;
|
||||
leafletRuntime: LeafletRuntime;
|
||||
}) {
|
||||
const map = useMap();
|
||||
const lastAppliedViewportRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
lastAppliedViewportRef.current = null;
|
||||
}, [map]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewport?.mode === "center") {
|
||||
const viewportKey = `center:${roundCoordinate(viewport.center.lat)}:${roundCoordinate(viewport.center.lng)}:${viewport.zoom}`;
|
||||
if (lastAppliedViewportRef.current === viewportKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastAppliedViewportRef.current = viewportKey;
|
||||
map.setView([viewport.center.lat, viewport.center.lng], viewport.zoom);
|
||||
return;
|
||||
}
|
||||
|
||||
const fitTarget = viewport?.target ?? "all";
|
||||
const fitPoints = resolveFitPointsWithFallback(markers, routes, fitTarget);
|
||||
if (fitPoints.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxZoom = viewport?.maxZoom;
|
||||
if (fitPoints.length === 1) {
|
||||
const [lat, lng] = fitPoints[0];
|
||||
const zoom = maxZoom
|
||||
? Math.min(SINGLE_LOCATION_ZOOM, maxZoom)
|
||||
: SINGLE_LOCATION_ZOOM;
|
||||
const viewportKey = `fit-single:${roundCoordinate(lat)}:${roundCoordinate(lng)}:${zoom}`;
|
||||
if (lastAppliedViewportRef.current === viewportKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastAppliedViewportRef.current = viewportKey;
|
||||
map.setView([lat, lng], zoom);
|
||||
return;
|
||||
}
|
||||
|
||||
const padding = viewport?.padding ?? DEFAULT_VIEWPORT_PADDING;
|
||||
const viewportKey = `fit:${fitTarget}:${padding}:${maxZoom ?? "none"}:${serializeFitPoints(fitPoints)}`;
|
||||
if (lastAppliedViewportRef.current === viewportKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastAppliedViewportRef.current = viewportKey;
|
||||
const bounds = leafletRuntime.latLngBounds(fitPoints);
|
||||
map.fitBounds(bounds, {
|
||||
maxZoom,
|
||||
padding: [padding, padding],
|
||||
});
|
||||
}, [leafletRuntime, map, markers, routes, viewport]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function MapObserver({
|
||||
onViewportChange,
|
||||
onMapReady,
|
||||
}: {
|
||||
onViewportChange: (state: MapViewportState) => void;
|
||||
onMapReady: (map: LeafletMap) => void;
|
||||
}) {
|
||||
const map = useMapEvents({
|
||||
moveend: () => {
|
||||
onViewportChange(readViewportState(map));
|
||||
},
|
||||
zoomend: () => {
|
||||
onViewportChange(readViewportState(map));
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onMapReady(map);
|
||||
onViewportChange(readViewportState(map));
|
||||
}, [map, onMapReady, onViewportChange]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveMarkerAriaLabel(marker: GeoMapMarker): string {
|
||||
if (marker.label && marker.description) {
|
||||
return `${marker.label}. ${marker.description}`;
|
||||
}
|
||||
|
||||
return (
|
||||
marker.label ??
|
||||
marker.description ??
|
||||
`Marker at ${marker.lat.toFixed(4)}, ${marker.lng.toFixed(4)}`
|
||||
);
|
||||
}
|
||||
|
||||
export const GeoMapEngine = memo(function GeoMapEngine({
|
||||
id,
|
||||
markers,
|
||||
routes,
|
||||
clustering,
|
||||
viewport,
|
||||
showZoomControl,
|
||||
tileUrl,
|
||||
mapAriaLabel,
|
||||
tooltipClassName,
|
||||
popupClassName,
|
||||
onMarkerClick,
|
||||
onRouteClick,
|
||||
onReadyChange,
|
||||
}: {
|
||||
id: string;
|
||||
markers: GeoMapMarker[];
|
||||
routes?: GeoMapRoute[];
|
||||
clustering?: GeoMapClustering;
|
||||
viewport?: GeoMapViewport;
|
||||
showZoomControl: boolean;
|
||||
tileUrl: string;
|
||||
mapAriaLabel: string;
|
||||
tooltipClassName?: string;
|
||||
popupClassName?: string;
|
||||
onMarkerClick?: (marker: GeoMapMarker) => void;
|
||||
onRouteClick?: (route: GeoMapRoute) => void;
|
||||
onReadyChange?: (isReady: boolean) => void;
|
||||
}) {
|
||||
const resolvedRoutes = routes ?? EMPTY_ROUTES;
|
||||
const [leafletRuntime, setLeafletRuntime] = useState<LeafletRuntime | null>(
|
||||
null,
|
||||
);
|
||||
const [mapInstance, setMapInstance] = useState<LeafletMap | null>(null);
|
||||
const [viewportState, setViewportState] = useState<MapViewportState | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleViewportChange = useCallback((nextState: MapViewportState) => {
|
||||
const normalized = normalizeViewportState(nextState);
|
||||
setViewportState((previousState) =>
|
||||
areViewportStatesEqual(previousState, normalized)
|
||||
? previousState
|
||||
: normalized,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
void import("leaflet").then((module) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLeafletRuntime({
|
||||
divIcon: module.divIcon,
|
||||
latLngBounds: module.latLngBounds,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isReady = leafletRuntime !== null;
|
||||
|
||||
useEffect(() => {
|
||||
onReadyChange?.(isReady);
|
||||
}, [isReady, onReadyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = mapInstance.getContainer();
|
||||
container.setAttribute("role", "region");
|
||||
container.setAttribute("aria-label", mapAriaLabel);
|
||||
}, [mapAriaLabel, mapInstance]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
mapInstance.closePopup();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [mapInstance]);
|
||||
|
||||
const initialView = useMemo(
|
||||
() => resolveInitialView(markers, resolvedRoutes, viewport),
|
||||
[markers, resolvedRoutes, viewport],
|
||||
);
|
||||
|
||||
const markerById = useMemo(() => {
|
||||
const map = new Map<string, GeoMapMarker>();
|
||||
markers.forEach((marker, index) => {
|
||||
map.set(marker.id ?? `marker-${index}`, marker);
|
||||
});
|
||||
return map;
|
||||
}, [markers]);
|
||||
|
||||
const clusterConfig = useMemo(
|
||||
() => ({
|
||||
enabled: clustering?.enabled === true,
|
||||
radius: clustering?.radius ?? CLUSTER_RADIUS_DEFAULT,
|
||||
maxZoom: clustering?.maxZoom ?? CLUSTER_MAX_ZOOM_DEFAULT,
|
||||
minPoints: clustering?.minPoints ?? CLUSTER_MIN_POINTS_DEFAULT,
|
||||
}),
|
||||
[clustering],
|
||||
);
|
||||
|
||||
const clusterIndex = useMemo(() => {
|
||||
if (!clusterConfig.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const index = new Supercluster<MarkerClusterPointProperties>({
|
||||
radius: clusterConfig.radius,
|
||||
maxZoom: clusterConfig.maxZoom,
|
||||
minPoints: clusterConfig.minPoints,
|
||||
});
|
||||
|
||||
const points = markers.map((marker, index) => {
|
||||
const markerId = marker.id ?? `marker-${index}`;
|
||||
return {
|
||||
type: "Feature" as const,
|
||||
id: markerId,
|
||||
geometry: {
|
||||
type: "Point" as const,
|
||||
coordinates: [marker.lng, marker.lat] as [number, number],
|
||||
},
|
||||
properties: {
|
||||
markerId,
|
||||
marker,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
index.load(points);
|
||||
return index;
|
||||
}, [
|
||||
clusterConfig.enabled,
|
||||
clusterConfig.maxZoom,
|
||||
clusterConfig.minPoints,
|
||||
clusterConfig.radius,
|
||||
markers,
|
||||
]);
|
||||
|
||||
const clusteredFeatures = useMemo(() => {
|
||||
if (!clusterConfig.enabled || !clusterIndex || !viewportState) {
|
||||
return [] as GeoMapClusterFeature[];
|
||||
}
|
||||
|
||||
return getClustersForDatelineAwareBbox(
|
||||
viewportState.bbox,
|
||||
viewportState.zoom,
|
||||
(bbox, zoom) =>
|
||||
clusterIndex.getClusters(bbox, zoom) as GeoMapClusterFeature[],
|
||||
);
|
||||
}, [clusterConfig.enabled, clusterIndex, viewportState]);
|
||||
|
||||
const renderMarker = useCallback(
|
||||
(
|
||||
marker: GeoMapMarker,
|
||||
markerKey: string,
|
||||
markerPositionOverride?: [number, number],
|
||||
) => {
|
||||
const markerPosition: [number, number] = markerPositionOverride ?? [
|
||||
marker.lat,
|
||||
marker.lng,
|
||||
];
|
||||
const tooltipMode = marker.tooltip ?? "hover";
|
||||
const tooltipContent = marker.label ?? marker.description;
|
||||
const icon = marker.icon;
|
||||
const markerAriaLabel = resolveMarkerAriaLabel(marker);
|
||||
|
||||
if (!leafletRuntime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const leafletIcon = resolveMarkerIcon(icon, leafletRuntime);
|
||||
if (leafletIcon) {
|
||||
return (
|
||||
<Marker
|
||||
key={markerKey}
|
||||
position={markerPosition}
|
||||
icon={leafletIcon}
|
||||
title={markerAriaLabel}
|
||||
alt={markerAriaLabel}
|
||||
eventHandlers={{
|
||||
click: () => onMarkerClick?.(marker),
|
||||
}}
|
||||
>
|
||||
<GeoMapOverlays
|
||||
tooltipMode={tooltipMode}
|
||||
tooltipContent={tooltipContent}
|
||||
label={marker.label}
|
||||
description={marker.description}
|
||||
tooltipClassName={tooltipClassName}
|
||||
popupClassName={popupClassName}
|
||||
/>
|
||||
</Marker>
|
||||
);
|
||||
}
|
||||
|
||||
const markerStroke =
|
||||
icon?.type === "dot"
|
||||
? (icon.borderColor ?? "var(--border)")
|
||||
: "var(--border)";
|
||||
const markerFill =
|
||||
icon?.type === "dot"
|
||||
? (icon.color ?? "var(--primary)")
|
||||
: "var(--primary)";
|
||||
const markerRadius = icon?.type === "dot" ? (icon.radius ?? 7) : 7;
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={markerKey}
|
||||
center={markerPosition}
|
||||
radius={markerRadius}
|
||||
pathOptions={{
|
||||
color: markerStroke,
|
||||
fillColor: markerFill,
|
||||
fillOpacity: 0.95,
|
||||
weight: 2,
|
||||
}}
|
||||
eventHandlers={{
|
||||
click: () => onMarkerClick?.(marker),
|
||||
}}
|
||||
>
|
||||
<GeoMapOverlays
|
||||
tooltipMode={tooltipMode}
|
||||
tooltipContent={tooltipContent}
|
||||
label={marker.label}
|
||||
description={marker.description}
|
||||
tooltipClassName={tooltipClassName}
|
||||
popupClassName={popupClassName}
|
||||
/>
|
||||
</CircleMarker>
|
||||
);
|
||||
},
|
||||
[leafletRuntime, onMarkerClick, popupClassName, tooltipClassName],
|
||||
);
|
||||
|
||||
if (!leafletRuntime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={initialView.center}
|
||||
zoom={initialView.zoom}
|
||||
zoomControl={false}
|
||||
className="h-full w-full"
|
||||
scrollWheelZoom
|
||||
>
|
||||
<TileLayer attribution={TILE_ATTRIBUTION} url={tileUrl} />
|
||||
{showZoomControl && <ZoomControl position="topright" />}
|
||||
<MapObserver
|
||||
onMapReady={setMapInstance}
|
||||
onViewportChange={handleViewportChange}
|
||||
/>
|
||||
<ViewportController
|
||||
leafletRuntime={leafletRuntime}
|
||||
markers={markers}
|
||||
routes={resolvedRoutes}
|
||||
viewport={viewport}
|
||||
/>
|
||||
|
||||
{resolvedRoutes.map((route, routeIndex) => {
|
||||
const routeKey = route.id ?? `${id}-route-${routeIndex}`;
|
||||
const positions = route.points.map((point) => [
|
||||
point.lat,
|
||||
point.lng,
|
||||
]) as [number, number][];
|
||||
const tooltipMode = route.tooltip ?? "hover";
|
||||
const tooltipContent = route.label ?? route.description;
|
||||
|
||||
return (
|
||||
<Polyline
|
||||
key={routeKey}
|
||||
positions={positions}
|
||||
pathOptions={{
|
||||
color: route.color ?? ROUTE_DEFAULT_COLOR,
|
||||
weight: route.weight ?? ROUTE_DEFAULT_WEIGHT,
|
||||
opacity: route.opacity ?? ROUTE_DEFAULT_OPACITY,
|
||||
dashArray: route.dashArray,
|
||||
}}
|
||||
eventHandlers={{
|
||||
click: () => onRouteClick?.(route),
|
||||
}}
|
||||
>
|
||||
<GeoMapOverlays
|
||||
tooltipMode={tooltipMode}
|
||||
tooltipContent={tooltipContent}
|
||||
label={route.label}
|
||||
description={route.description}
|
||||
tooltipClassName={tooltipClassName}
|
||||
popupClassName={popupClassName}
|
||||
/>
|
||||
</Polyline>
|
||||
);
|
||||
})}
|
||||
|
||||
{clusterConfig.enabled && clusterIndex && viewportState
|
||||
? clusteredFeatures.map((feature, index) => {
|
||||
const [lng, lat] = feature.geometry.coordinates;
|
||||
const properties = (feature.properties ??
|
||||
{}) as MarkerClusterPointProperties;
|
||||
|
||||
if (
|
||||
properties.cluster &&
|
||||
typeof properties.cluster_id === "number"
|
||||
) {
|
||||
const pointCount = properties.point_count ?? 0;
|
||||
const clusterId = properties.cluster_id;
|
||||
const clusterIcon = createClusterIcon(pointCount, leafletRuntime);
|
||||
const clusterAriaLabel = `Cluster containing ${pointCount} locations`;
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={`cluster-${clusterId}`}
|
||||
position={[lat, lng]}
|
||||
icon={clusterIcon}
|
||||
title={clusterAriaLabel}
|
||||
alt={clusterAriaLabel}
|
||||
eventHandlers={{
|
||||
click: () => {
|
||||
if (!mapInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expansionZoom = toSafeExpansionZoom(
|
||||
clusterIndex.getClusterExpansionZoom(clusterId),
|
||||
{
|
||||
maxZoom: 22,
|
||||
fallback:
|
||||
(viewportState.zoom ?? DEFAULT_VIEW_ZOOM) + 2,
|
||||
},
|
||||
);
|
||||
mapInstance.flyTo([lat, lng], expansionZoom);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const marker =
|
||||
properties.marker ??
|
||||
markerById.get(properties.markerId ?? `marker-${index}`);
|
||||
if (!marker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const markerKey =
|
||||
marker.id ?? properties.markerId ?? `${id}-cluster-leaf-${index}`;
|
||||
return renderMarker(marker, markerKey, [lat, lng]);
|
||||
})
|
||||
: markers.map((marker, index) =>
|
||||
renderMarker(marker, marker.id ?? `${id}-marker-${index}`),
|
||||
)}
|
||||
</MapContainer>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { DivIcon } from "leaflet";
|
||||
import type { GeoMapMarker } from "./schema";
|
||||
|
||||
type LeafletIconRuntime = Pick<typeof import("leaflet"), "divIcon">;
|
||||
|
||||
function isSafeHttpUrl(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function createEmojiIcon(
|
||||
icon: Extract<NonNullable<GeoMapMarker["icon"]>, { type: "emoji" }>,
|
||||
leafletRuntime: LeafletIconRuntime,
|
||||
): DivIcon {
|
||||
const size = icon.size ?? 24;
|
||||
const background = icon.bgColor ?? "var(--card)";
|
||||
const border = icon.borderColor ?? "var(--border)";
|
||||
|
||||
return leafletRuntime.divIcon({
|
||||
className: "",
|
||||
html: `<span style="
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
width:${size}px;
|
||||
height:${size}px;
|
||||
border-radius:999px;
|
||||
background:${background};
|
||||
border:1px solid ${border};
|
||||
font-size:${Math.round(size * 0.62)}px;
|
||||
line-height:1;
|
||||
box-shadow:0 1px 3px oklch(from var(--foreground) l c h / 0.22);
|
||||
">${escapeHtml(icon.value)}</span>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
popupAnchor: [0, -Math.round(size / 2)],
|
||||
tooltipAnchor: [0, -Math.round(size / 2)],
|
||||
});
|
||||
}
|
||||
|
||||
function createImageIcon(
|
||||
icon: Extract<NonNullable<GeoMapMarker["icon"]>, { type: "image" }>,
|
||||
leafletRuntime: LeafletIconRuntime,
|
||||
): DivIcon {
|
||||
const width = icon.width ?? 28;
|
||||
const height = icon.height ?? 28;
|
||||
const borderRadius = icon.borderRadius ?? Math.min(width, height) / 2;
|
||||
const border = icon.borderColor ?? "var(--border)";
|
||||
|
||||
return leafletRuntime.divIcon({
|
||||
className: "",
|
||||
html: `<span style="
|
||||
display:block;
|
||||
width:${width}px;
|
||||
height:${height}px;
|
||||
border-radius:${borderRadius}px;
|
||||
overflow:hidden;
|
||||
border:1px solid ${border};
|
||||
background:var(--card);
|
||||
box-shadow:0 1px 3px oklch(from var(--foreground) l c h / 0.22);
|
||||
"><img src="${escapeHtml(icon.url)}" alt="" style="width:100%;height:100%;object-fit:cover;display:block;" /></span>`,
|
||||
iconSize: [width, height],
|
||||
iconAnchor: [width / 2, height / 2],
|
||||
popupAnchor: [0, -Math.round(height / 2)],
|
||||
tooltipAnchor: [0, -Math.round(height / 2)],
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterIcon(
|
||||
count: number,
|
||||
leafletRuntime: LeafletIconRuntime,
|
||||
): DivIcon {
|
||||
const size = count >= 100 ? 42 : count >= 10 ? 38 : 34;
|
||||
const background = "var(--primary)";
|
||||
const border = "var(--background)";
|
||||
|
||||
return leafletRuntime.divIcon({
|
||||
className: "",
|
||||
html: `<span style="
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
width:${size}px;
|
||||
height:${size}px;
|
||||
border-radius:999px;
|
||||
background:${background};
|
||||
border:2px solid ${border};
|
||||
color:var(--primary-foreground);
|
||||
font-size:12px;
|
||||
font-weight:700;
|
||||
line-height:1;
|
||||
box-shadow:0 2px 6px oklch(from var(--foreground) l c h / 0.25);
|
||||
">${count}</span>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
popupAnchor: [0, -Math.round(size / 2)],
|
||||
tooltipAnchor: [0, -Math.round(size / 2)],
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveMarkerIcon(
|
||||
icon: GeoMapMarker["icon"] | undefined,
|
||||
leafletRuntime: LeafletIconRuntime,
|
||||
): DivIcon | null {
|
||||
if (icon?.type === "emoji") {
|
||||
return createEmojiIcon(icon, leafletRuntime);
|
||||
}
|
||||
|
||||
if (icon?.type === "image" && isSafeHttpUrl(icon.url)) {
|
||||
return createImageIcon(icon, leafletRuntime);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Popup, Tooltip, cn } from "./_adapter";
|
||||
|
||||
function GeoMapPopupContent({
|
||||
label,
|
||||
description,
|
||||
}: {
|
||||
label?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{label && (
|
||||
<p className="block text-sm leading-tight font-semibold tracking-tight text-foreground">
|
||||
{label}
|
||||
</p>
|
||||
)}
|
||||
{description && (
|
||||
<p className="block text-xs leading-relaxed text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GeoMapTooltipContent({ text }: { text: string }) {
|
||||
return <span className="block">{text}</span>;
|
||||
}
|
||||
|
||||
export function GeoMapOverlays({
|
||||
tooltipMode,
|
||||
tooltipContent,
|
||||
label,
|
||||
description,
|
||||
tooltipClassName,
|
||||
popupClassName,
|
||||
}: {
|
||||
tooltipMode: "none" | "hover" | "always";
|
||||
tooltipContent?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
tooltipClassName?: string;
|
||||
popupClassName?: string;
|
||||
}) {
|
||||
const hasPopup = Boolean(label || description);
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
||||
const shouldRenderTooltip =
|
||||
tooltipMode !== "none" && tooltipContent && (!hasPopup || !isPopupOpen);
|
||||
const popupEventHandlers = useMemo(
|
||||
() => ({
|
||||
add: () => setIsPopupOpen(true),
|
||||
remove: () => setIsPopupOpen(false),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{shouldRenderTooltip && (
|
||||
<Tooltip
|
||||
direction="top"
|
||||
permanent={tooltipMode === "always"}
|
||||
className={cn("geo-map-tooltip", tooltipClassName)}
|
||||
>
|
||||
<GeoMapTooltipContent text={tooltipContent} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasPopup && (
|
||||
<Popup
|
||||
className={cn("geo-map-popup", popupClassName)}
|
||||
closeButton
|
||||
closeOnEscapeKey
|
||||
minWidth={0}
|
||||
maxWidth={288}
|
||||
eventHandlers={popupEventHandlers}
|
||||
>
|
||||
<GeoMapPopupContent label={label} description={description} />
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
.root[data-slot="geo-map"] {
|
||||
--geo-map-canvas-bg: var(--muted);
|
||||
--geo-map-tooltip-bg: var(--foreground);
|
||||
--geo-map-tooltip-fg: var(--background);
|
||||
--geo-map-tooltip-shadow: 0 8px 20px
|
||||
oklch(from var(--foreground) l c h / 0.18);
|
||||
--geo-map-tooltip-radius: calc(var(--radius) - 2px);
|
||||
--geo-map-tooltip-padding: 0.375rem 0.625rem;
|
||||
--geo-map-tooltip-font-size: 0.75rem;
|
||||
--geo-map-tooltip-font-weight: 500;
|
||||
--geo-map-tooltip-line-height: 1.2;
|
||||
--geo-map-popup-margin-bottom: 12px;
|
||||
--geo-map-popup-border: var(--border);
|
||||
--geo-map-popup-radius: calc(var(--radius) + 2px);
|
||||
--geo-map-popup-bg: oklch(from var(--popover) l c h / 0.96);
|
||||
--geo-map-popup-fg: var(--popover-foreground);
|
||||
--geo-map-popup-shadow: 0 10px 30px oklch(from var(--foreground) l c h / 0.12);
|
||||
--geo-map-popup-blur: 8px;
|
||||
--geo-map-popup-content-padding: 0.625rem 0.75rem;
|
||||
--geo-map-popup-max-width: min(80vw, 18rem);
|
||||
--geo-map-popup-font-family: var(
|
||||
--font-sans,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
sans-serif
|
||||
);
|
||||
--geo-map-zoom-bg: oklch(from var(--background) l c h / 0.78);
|
||||
--geo-map-zoom-fg: var(--foreground);
|
||||
--geo-map-zoom-border: var(--border);
|
||||
--geo-map-zoom-hover-bg: oklch(from var(--accent) l c h / 0.82);
|
||||
--geo-map-zoom-hover-fg: var(--accent-foreground);
|
||||
--geo-map-zoom-disabled-bg: oklch(from var(--muted) l c h / 0.72);
|
||||
--geo-map-zoom-disabled-fg: var(--muted-foreground);
|
||||
--geo-map-zoom-shadow: 0 1px 2px oklch(from var(--foreground) l c h / 0.08);
|
||||
--geo-map-zoom-focus-ring: var(--ring);
|
||||
--geo-map-zoom-radius: 0.5rem;
|
||||
--geo-map-zoom-size: 2.25rem;
|
||||
--geo-map-zoom-font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"] :global(.leaflet-container) {
|
||||
background: var(--geo-map-canvas-bg);
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"] :global(.leaflet-control-zoom) {
|
||||
border: 1px solid var(--geo-map-zoom-border);
|
||||
box-shadow: var(--geo-map-zoom-shadow);
|
||||
background: var(--geo-map-zoom-bg);
|
||||
backdrop-filter: blur(var(--geo-map-popup-blur));
|
||||
-webkit-backdrop-filter: blur(var(--geo-map-popup-blur));
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"] :global(.leaflet-control-zoom.leaflet-bar) {
|
||||
border-radius: var(--geo-map-zoom-radius) !important;
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--geo-map-zoom-size);
|
||||
height: var(--geo-map-zoom-size);
|
||||
line-height: 1;
|
||||
text-indent: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--geo-map-zoom-fg);
|
||||
font-size: var(--geo-map-zoom-font-size);
|
||||
font-weight: 500;
|
||||
box-shadow: none;
|
||||
cursor: default;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
color 150ms ease,
|
||||
border-color 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
opacity 150ms ease;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a + a) {
|
||||
border-top: 1px solid var(--geo-map-zoom-border);
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:first-child),
|
||||
.root[data-slot="geo-map"]
|
||||
:global(.leaflet-touch .leaflet-control-zoom a:first-child),
|
||||
.root[data-slot="geo-map"]
|
||||
:global(.leaflet-control-zoom .leaflet-control-zoom-in) {
|
||||
border-radius: var(--geo-map-zoom-radius) var(--geo-map-zoom-radius) 0 0 !important;
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:last-child),
|
||||
.root[data-slot="geo-map"]
|
||||
:global(.leaflet-touch .leaflet-control-zoom a:last-child),
|
||||
.root[data-slot="geo-map"]
|
||||
:global(.leaflet-control-zoom .leaflet-control-zoom-out) {
|
||||
border-top: 0;
|
||||
border-radius: 0 0 var(--geo-map-zoom-radius) var(--geo-map-zoom-radius) !important;
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:hover) {
|
||||
background: var(--geo-map-zoom-hover-bg);
|
||||
color: var(--geo-map-zoom-hover-fg);
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:focus),
|
||||
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:focus-visible) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
outline: 2px solid var(--geo-map-zoom-focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a.leaflet-disabled),
|
||||
.root[data-slot="geo-map"]
|
||||
:global(.leaflet-control-zoom a.leaflet-disabled:hover) {
|
||||
background: var(--geo-map-zoom-disabled-bg);
|
||||
color: var(--geo-map-zoom-disabled-fg);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"] :global(.leaflet-tooltip.geo-map-tooltip) {
|
||||
border: 0;
|
||||
border-radius: var(--geo-map-tooltip-radius);
|
||||
background: var(--geo-map-tooltip-bg);
|
||||
color: var(--geo-map-tooltip-fg);
|
||||
box-shadow: var(--geo-map-tooltip-shadow);
|
||||
font-size: var(--geo-map-tooltip-font-size);
|
||||
font-weight: var(--geo-map-tooltip-font-weight);
|
||||
line-height: var(--geo-map-tooltip-line-height);
|
||||
padding: var(--geo-map-tooltip-padding);
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"]
|
||||
:global(.leaflet-tooltip-top.geo-map-tooltip::before) {
|
||||
border-top-color: var(--geo-map-tooltip-bg);
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"]
|
||||
:global(.leaflet-tooltip-bottom.geo-map-tooltip::before) {
|
||||
border-bottom-color: var(--geo-map-tooltip-bg);
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"]
|
||||
:global(.leaflet-tooltip-left.geo-map-tooltip::before) {
|
||||
border-left-color: var(--geo-map-tooltip-bg);
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"]
|
||||
:global(.leaflet-tooltip-right.geo-map-tooltip::before) {
|
||||
border-right-color: var(--geo-map-tooltip-bg);
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"] :global(.leaflet-popup.geo-map-popup) {
|
||||
margin-bottom: var(--geo-map-popup-margin-bottom);
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"]
|
||||
:global(.leaflet-popup.geo-map-popup .leaflet-popup-content-wrapper) {
|
||||
border: 1px solid var(--geo-map-popup-border);
|
||||
border-radius: var(--geo-map-popup-radius);
|
||||
background: var(--geo-map-popup-bg);
|
||||
color: var(--geo-map-popup-fg);
|
||||
box-shadow: var(--geo-map-popup-shadow);
|
||||
backdrop-filter: blur(var(--geo-map-popup-blur));
|
||||
-webkit-backdrop-filter: blur(var(--geo-map-popup-blur));
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"]
|
||||
:global(.leaflet-popup.geo-map-popup .leaflet-popup-content) {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
width: max-content;
|
||||
max-width: var(--geo-map-popup-max-width);
|
||||
padding: var(--geo-map-popup-content-padding);
|
||||
font-family: var(--geo-map-popup-font-family);
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"]
|
||||
:global(.leaflet-popup.geo-map-popup .leaflet-popup-content p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"]
|
||||
:global(.leaflet-popup.geo-map-popup .leaflet-popup-tip-container) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"]
|
||||
:global(.leaflet-popup.geo-map-popup .leaflet-popup-close-button) {
|
||||
color: var(--geo-map-popup-fg);
|
||||
opacity: 0.75;
|
||||
top: 0.25rem;
|
||||
right: 0.25rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"]
|
||||
:global(.leaflet-popup.geo-map-popup .leaflet-popup-close-button:hover) {
|
||||
opacity: 1;
|
||||
background: oklch(from var(--muted) l c h / 0.65);
|
||||
}
|
||||
|
||||
.root[data-slot="geo-map"]
|
||||
:global(
|
||||
.leaflet-popup.geo-map-popup .leaflet-popup-close-button:focus-visible
|
||||
) {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { cn } from "./_adapter";
|
||||
import { GeoMapEngine } from "./geo-map-engine";
|
||||
import styles from "./geo-map-theme.module.css";
|
||||
import type { GeoMapProps, GeoMapStyle } from "./schema";
|
||||
|
||||
const LIGHT_TILE_URL =
|
||||
"https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
|
||||
const DARK_TILE_URL =
|
||||
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
|
||||
|
||||
function getSystemTheme(): "light" | "dark" {
|
||||
if (typeof window === "undefined") return "light";
|
||||
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
function getDocumentTheme(): "light" | "dark" | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
const root = document.documentElement;
|
||||
const dataTheme = root.getAttribute("data-theme")?.toLowerCase();
|
||||
if (dataTheme === "dark") return "dark";
|
||||
if (dataTheme === "light") return "light";
|
||||
if (root.classList.contains("dark")) return "dark";
|
||||
if (root.classList.contains("light")) return "light";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function useInheritedTheme(): "light" | "dark" {
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
return getDocumentTheme() ?? getSystemTheme();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => setTheme(getDocumentTheme() ?? getSystemTheme());
|
||||
|
||||
const mql = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||
mql?.addEventListener("change", update);
|
||||
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "data-theme"],
|
||||
});
|
||||
|
||||
return () => {
|
||||
mql?.removeEventListener("change", update);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
function resolveMapAriaLabel(title?: string, description?: string): string {
|
||||
if (title && description) {
|
||||
return `${title}. ${description}`;
|
||||
}
|
||||
|
||||
return title ?? description ?? "Geographic map";
|
||||
}
|
||||
|
||||
export const GeoMap = memo(function GeoMap({
|
||||
id,
|
||||
role: _role,
|
||||
receipt: _receipt,
|
||||
title,
|
||||
description,
|
||||
markers,
|
||||
routes,
|
||||
clustering,
|
||||
viewport,
|
||||
showZoomControl = true,
|
||||
theme,
|
||||
className,
|
||||
style,
|
||||
tooltipClassName,
|
||||
popupClassName,
|
||||
onMarkerClick,
|
||||
onRouteClick,
|
||||
}: GeoMapProps) {
|
||||
const inheritedTheme = useInheritedTheme();
|
||||
const resolvedTheme = theme ?? inheritedTheme;
|
||||
const [isMapReady, setIsMapReady] = useState(false);
|
||||
const tileUrl = resolvedTheme === "dark" ? DARK_TILE_URL : LIGHT_TILE_URL;
|
||||
const mapAriaLabel = resolveMapAriaLabel(title, description);
|
||||
const resolvedRootStyle: GeoMapStyle = {
|
||||
"--geo-map-canvas-bg":
|
||||
resolvedTheme === "dark" ? "var(--background)" : "var(--muted)",
|
||||
...style,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("w-full min-w-80", styles.root, className)}
|
||||
style={resolvedRootStyle}
|
||||
data-slot="geo-map"
|
||||
data-tool-ui-id={id}
|
||||
>
|
||||
<div
|
||||
className="bg-muted/20 relative h-[320px] w-full overflow-hidden rounded-lg border"
|
||||
role="region"
|
||||
aria-label={mapAriaLabel}
|
||||
>
|
||||
<GeoMapEngine
|
||||
id={id}
|
||||
markers={markers}
|
||||
routes={routes}
|
||||
clustering={clustering}
|
||||
viewport={viewport}
|
||||
showZoomControl={showZoomControl}
|
||||
tileUrl={tileUrl}
|
||||
mapAriaLabel={mapAriaLabel}
|
||||
tooltipClassName={tooltipClassName}
|
||||
popupClassName={popupClassName}
|
||||
onMarkerClick={onMarkerClick}
|
||||
onRouteClick={onRouteClick}
|
||||
onReadyChange={setIsMapReady}
|
||||
/>
|
||||
|
||||
{(title || description) && (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute top-3 left-3 z-[900]",
|
||||
"max-w-[min(75%,22rem)] rounded-lg border border-border/70 bg-background/70 px-3 py-2",
|
||||
"shadow-sm backdrop-blur-md",
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<p className="text-foreground text-sm leading-tight font-semibold">
|
||||
{title}
|
||||
</p>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-muted-foreground mt-1 text-xs leading-snug">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMapReady && (
|
||||
<div
|
||||
data-slot="geo-map-loading"
|
||||
className="bg-muted/30 text-muted-foreground pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<span data-slot="geo-map-loading-label">Loading map...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import "./leaflet-overrides.css";
|
||||
|
||||
export { GeoMap } from "./geo-map";
|
||||
export {
|
||||
type GeoMapClustering,
|
||||
type GeoMapFitTarget,
|
||||
type GeoMapMarker,
|
||||
type GeoMapMarkerIcon,
|
||||
type GeoMapStyle,
|
||||
type GeoMapRoute,
|
||||
type GeoMapViewport,
|
||||
type GeoMapProps,
|
||||
type GeoMapClientProps,
|
||||
type SerializableGeoMap,
|
||||
} from "./schema";
|
||||
@@ -0,0 +1,37 @@
|
||||
/* Leaflet overrides for theme integration */
|
||||
|
||||
.leaflet-container {
|
||||
background: var(--muted);
|
||||
/* Isolate Leaflet's high z-indices to prevent them from escaping the container */
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .leaflet-container {
|
||||
background: hsl(240 0 10%);
|
||||
}
|
||||
|
||||
.leaflet-control-attribution {
|
||||
background: oklch(from var(--background) l c h / 0.8) !important;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .leaflet-control-attribution {
|
||||
background: hsl(240 10% 3.9% / 0.8) !important;
|
||||
color: hsl(240 5% 64.9%);
|
||||
}
|
||||
|
||||
.leaflet-control-attribution a {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .leaflet-control-attribution a {
|
||||
color: hsl(240 5% 64.9%);
|
||||
}
|
||||
|
||||
.leaflet-control-attribution a:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .leaflet-control-attribution a:hover {
|
||||
color: hsl(0 0% 98%);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { z } from "zod";
|
||||
import type { CSSProperties } from "react";
|
||||
import { defineToolUiContract } from "../shared/contract";
|
||||
import {
|
||||
ToolUIIdSchema,
|
||||
ToolUIReceiptSchema,
|
||||
ToolUIRoleSchema,
|
||||
} from "../shared/schema";
|
||||
|
||||
const LatitudeSchema = z.number().finite().min(-90).max(90);
|
||||
const LongitudeSchema = z.number().finite().min(-180).max(180);
|
||||
const HttpUrlSchema = z
|
||||
.string()
|
||||
.url()
|
||||
.refine((value) => /^https?:\/\//i.test(value), {
|
||||
message: "Expected an http or https URL.",
|
||||
});
|
||||
|
||||
const GeoMapMarkerIconDotSchema = z.object({
|
||||
type: z.literal("dot"),
|
||||
color: z.string().optional(),
|
||||
borderColor: z.string().optional(),
|
||||
radius: z.number().min(3).max(16).optional(),
|
||||
});
|
||||
|
||||
const GeoMapMarkerIconEmojiSchema = z.object({
|
||||
type: z.literal("emoji"),
|
||||
value: z.string().min(1),
|
||||
size: z.number().min(16).max(40).optional(),
|
||||
bgColor: z.string().optional(),
|
||||
borderColor: z.string().optional(),
|
||||
});
|
||||
|
||||
const GeoMapMarkerIconImageSchema = z.object({
|
||||
type: z.literal("image"),
|
||||
url: HttpUrlSchema,
|
||||
width: z.number().min(16).max(64).optional(),
|
||||
height: z.number().min(16).max(64).optional(),
|
||||
borderRadius: z.number().min(0).max(999).optional(),
|
||||
borderColor: z.string().optional(),
|
||||
});
|
||||
|
||||
export const GeoMapMarkerIconSchema = z.union([
|
||||
GeoMapMarkerIconDotSchema,
|
||||
GeoMapMarkerIconEmojiSchema,
|
||||
GeoMapMarkerIconImageSchema,
|
||||
]);
|
||||
|
||||
export type GeoMapMarkerIcon = z.infer<typeof GeoMapMarkerIconSchema>;
|
||||
|
||||
export const GeoMapMarkerSchema = z.object({
|
||||
id: z.string().min(1).optional(),
|
||||
lat: LatitudeSchema,
|
||||
lng: LongitudeSchema,
|
||||
label: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
tooltip: z.enum(["none", "hover", "always"]).optional(),
|
||||
icon: GeoMapMarkerIconSchema.optional(),
|
||||
});
|
||||
|
||||
export type GeoMapMarker = z.infer<typeof GeoMapMarkerSchema>;
|
||||
|
||||
export const GeoMapRoutePointSchema = z.object({
|
||||
lat: LatitudeSchema,
|
||||
lng: LongitudeSchema,
|
||||
});
|
||||
|
||||
export const GeoMapRouteSchema = z.object({
|
||||
id: z.string().min(1).optional(),
|
||||
points: z.array(GeoMapRoutePointSchema).min(2),
|
||||
label: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
tooltip: z.enum(["none", "hover", "always"]).optional(),
|
||||
color: z.string().optional(),
|
||||
weight: z.number().min(1).max(12).optional(),
|
||||
opacity: z.number().min(0).max(1).optional(),
|
||||
dashArray: z.string().optional(),
|
||||
});
|
||||
|
||||
export type GeoMapRoute = z.infer<typeof GeoMapRouteSchema>;
|
||||
|
||||
export const GeoMapClusteringSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
radius: z.number().min(20).max(120).optional(),
|
||||
maxZoom: z.number().min(1).max(22).optional(),
|
||||
minPoints: z.number().min(2).max(20).optional(),
|
||||
});
|
||||
|
||||
export type GeoMapClustering = z.infer<typeof GeoMapClusteringSchema>;
|
||||
|
||||
export const GeoMapFitTargetSchema = z.enum(["markers", "routes", "all"]);
|
||||
export type GeoMapFitTarget = z.infer<typeof GeoMapFitTargetSchema>;
|
||||
|
||||
const GeoMapFitViewportSchema = z.object({
|
||||
mode: z.literal("fit"),
|
||||
padding: z.number().nonnegative().optional(),
|
||||
maxZoom: z.number().min(1).max(22).optional(),
|
||||
target: GeoMapFitTargetSchema.optional(),
|
||||
});
|
||||
|
||||
const GeoMapCenterViewportSchema = z.object({
|
||||
mode: z.literal("center"),
|
||||
center: z.object({
|
||||
lat: LatitudeSchema,
|
||||
lng: LongitudeSchema,
|
||||
}),
|
||||
zoom: z.number().min(1).max(22),
|
||||
});
|
||||
|
||||
export const GeoMapViewportSchema = z.union([
|
||||
GeoMapFitViewportSchema,
|
||||
GeoMapCenterViewportSchema,
|
||||
]);
|
||||
|
||||
export type GeoMapViewport = z.infer<typeof GeoMapViewportSchema>;
|
||||
|
||||
export const GeoMapPropsSchema = z
|
||||
.object({
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
receipt: ToolUIReceiptSchema.optional(),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
markers: z.array(GeoMapMarkerSchema).min(1),
|
||||
routes: z.array(GeoMapRouteSchema).optional(),
|
||||
clustering: GeoMapClusteringSchema.optional(),
|
||||
viewport: GeoMapViewportSchema.optional(),
|
||||
showZoomControl: z.boolean().optional(),
|
||||
theme: z.enum(["light", "dark"]).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const seenMarkerIds = new Set<string>();
|
||||
|
||||
value.markers.forEach((marker, index) => {
|
||||
if (!marker.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (seenMarkerIds.has(marker.id)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["markers", index, "id"],
|
||||
message: `Duplicate marker id "${marker.id}".`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
seenMarkerIds.add(marker.id);
|
||||
});
|
||||
|
||||
const seenRouteIds = new Set<string>();
|
||||
value.routes?.forEach((route, index) => {
|
||||
if (!route.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (seenRouteIds.has(route.id)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["routes", index, "id"],
|
||||
message: `Duplicate route id "${route.id}".`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
seenRouteIds.add(route.id);
|
||||
});
|
||||
});
|
||||
|
||||
export type GeoMapStyle = CSSProperties &
|
||||
Partial<Record<`--${string}`, string | number>>;
|
||||
|
||||
export type GeoMapClientProps = {
|
||||
className?: string;
|
||||
style?: GeoMapStyle;
|
||||
tooltipClassName?: string;
|
||||
popupClassName?: string;
|
||||
onMarkerClick?: (marker: GeoMapMarker) => void;
|
||||
onRouteClick?: (route: GeoMapRoute) => void;
|
||||
};
|
||||
|
||||
export type GeoMapProps = z.infer<typeof GeoMapPropsSchema> & GeoMapClientProps;
|
||||
|
||||
export const SerializableGeoMapSchema = GeoMapPropsSchema;
|
||||
|
||||
export type SerializableGeoMap = z.infer<typeof SerializableGeoMapSchema>;
|
||||
|
||||
const SerializableGeoMapSchemaContract = defineToolUiContract(
|
||||
"GeoMap",
|
||||
SerializableGeoMapSchema,
|
||||
);
|
||||
|
||||
export const parseSerializableGeoMap: (input: unknown) => SerializableGeoMap =
|
||||
SerializableGeoMapSchemaContract.parse;
|
||||
|
||||
export const safeParseSerializableGeoMap: (
|
||||
input: unknown,
|
||||
) => SerializableGeoMap | null = SerializableGeoMapSchemaContract.safeParse;
|
||||
@@ -11,6 +11,26 @@ export interface ToolUiEntry {
|
||||
/* Every entry lazy-loads both the component and its zod contract so the chat bundle only pays
|
||||
for components a transcript actually uses. Names mirror upstream tool-ui component slugs. */
|
||||
export const TOOL_UI_REGISTRY: Record<string, ToolUiEntry> = {
|
||||
'audio': {
|
||||
Component: lazy(() => import('./components/audio').then((m) => ({ default: m.Audio }))),
|
||||
loadSchema: () => import('./components/audio/schema').then((m) => m.SerializableAudioSchema),
|
||||
},
|
||||
'chart': {
|
||||
Component: lazy(() => import('./components/chart').then((m) => ({ default: m.Chart }))),
|
||||
loadSchema: () => import('./components/chart/schema').then((m) => m.SerializableChartSchema),
|
||||
},
|
||||
'code-block': {
|
||||
Component: lazy(() => import('./components/code-block').then((m) => ({ default: m.CodeBlock }))),
|
||||
loadSchema: () => import('./components/code-block/schema').then((m) => m.SerializableCodeBlockSchema),
|
||||
},
|
||||
'code-diff': {
|
||||
Component: lazy(() => import('./components/code-diff').then((m) => ({ default: m.CodeDiff }))),
|
||||
loadSchema: () => import('./components/code-diff/schema').then((m) => m.SerializableCodeDiffSchema),
|
||||
},
|
||||
'geo-map': {
|
||||
Component: lazy(() => import('./components/geo-map').then((m) => ({ default: m.GeoMap }))),
|
||||
loadSchema: () => import('./components/geo-map/schema').then((m) => m.SerializableGeoMapSchema),
|
||||
},
|
||||
'approval-card': {
|
||||
Component: lazy(() => import('./components/approval-card').then((m) => ({ default: m.ApprovalCard }))),
|
||||
loadSchema: () => import('./components/approval-card/schema').then((m) => m.SerializableApprovalCardSchema),
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "@toolui/lib/utils";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"];
|
||||
}) {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color,
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
Reference in New Issue
Block a user