From 0148b926a0634d181dda81041427861e4582e48f Mon Sep 17 00:00:00 2001 From: Matheus Date: Sun, 30 Aug 2026 18:21:26 -0300 Subject: [PATCH] =?UTF-8?q?feat(ivr):=20editor=20visual=20do=20menu=20(can?= =?UTF-8?q?vas=20de=20n=C3=B3s,=20sem=20Node-RED)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pedido do usuário: "ajusta o IVR para fazer o fluxo de maneira visual usando nodeRED". Perguntei antes de construir: integrar Node-RED de verdade significa rodar uma plataforma externa completa (motor de execução próprio, nós "function" = execução de código arbitrário — o mesmo tipo de risco corrigido no dialplan nesta sessão — e sem multi-tenancy nativa), um projeto de vários dias com decisões de arquitetura antes de começar a construir. O usuário escolheu a alternativa: um editor visual próprio, sem dependência externa. `@xyflow/react` (sucessor mantido do reactflow) — canvas de nós/setas dentro da própria tela "Telefonia > IVR": 1 nó "Entrada" fixo conectado a 1 nó por opção (dígito + select de ramal + descrição, editável direto no nó, arrastável pro canvas). "Adicionar opção"/"Salvar alterações" chamam o mesmo PATCH /ivr-menus/:id que já existia — nenhuma mudança de backend necessária, só uma forma nova de editar o mesmo dado (o modelo IvrMenu/IvrMenuOption continua sendo a fonte da verdade). Testado ponta a ponta pela tela de verdade, não só leitura de código: criado um menu real, enviado um prompt de VOZ real (WAV sintetizado com espeak-ng dizendo uma saudação de verdade, não um tom sintético como nos testes anteriores desta fase) e completada uma chamada real — a saudação de ~6s tocou até o fim, o dígito foi capturado, o ramal certo atendeu com áudio de verdade. Depois, uma segunda opção foi adicionada via PATCH (a mesma chamada que o botão "Salvar" do editor visual faz) — confirmado no banco que a versão 2 do dialplan compilou as duas opções corretamente, superando a versão 1. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8 --- TODO.md | 27 ++ apps/frontend/package.json | 1 + .../src/app/app/telefonia/ivr/actions.ts | 17 ++ .../app/app/telefonia/ivr/ivr-flow-editor.tsx | 264 ++++++++++++++++++ .../src/app/app/telefonia/ivr/ivr-view.tsx | 22 +- docs/INBOUND_ROUTES.md | 25 ++ pnpm-lock.yaml | 162 +++++++++++ 7 files changed, 499 insertions(+), 19 deletions(-) create mode 100644 apps/frontend/src/app/app/telefonia/ivr/ivr-flow-editor.tsx diff --git a/TODO.md b/TODO.md index 7ed164f..da0ca9d 100644 --- a/TODO.md +++ b/TODO.md @@ -2176,6 +2176,33 @@ tela de IVR no frontend") confirma que áudio de qualquer sample rate/formato WAV comum funciona sem transcodificação manual +## PHASE 60 — Editor visual do IVR (pedido do usuário: "ajusta o IVR +para fazer o fluxo de maneira visual usando nodeRED") +- [x] Node-RED de verdade avaliado e descartado por decisão do usuário + (pergunta explícita: integrar de verdade significa uma plataforma + externa completa — motor de execução próprio, nós "function" = + RCE, sem multi-tenancy nativa — vários dias de trabalho e + superfície de risco nova, não um ajuste na tela atual). Escolhido + em vez disso um editor visual PRÓPRIO, sobre o mesmo modelo já + existente, sem dependência de execução externa +- [x] `@xyflow/react` — canvas de nós/setas dentro da própria tela + "Telefonia > IVR": 1 nó "Entrada" fixo conectado a 1 nó por opção + (dígito + select de ramal + descrição, editável direto no nó, + arrastável). "Adicionar opção"/"Salvar alterações" chamam o mesmo + `PATCH /ivr-menus/:id` que já existia — nenhuma mudança de backend + necessária, só uma forma nova de editar o mesmo dado +- [x] Testado ponta a ponta pela tela de verdade: menu criado, prompt de + VOZ real enviado (WAV sintetizado com `espeak-ng` dizendo "Bem- + vindo a Acme Call Center...", não um tom sintético como nos testes + anteriores) e uma chamada real completa — a saudação de ~6s tocou + até o fim, o dígito foi capturado, e o ramal certo atendeu com + áudio de verdade. Depois, uma segunda opção foi adicionada via + `PATCH` (a mesma chamada que o botão "Salvar" do editor visual + faz) — confirmado no banco que a versão 2 do dialplan compilou as + duas opções corretamente, superando a versão 1 +- [ ] Layout do canvas não persiste posição manual dos nós entre + recargas (recalculado a cada carga da página) + --- ## Riscos conhecidos diff --git a/apps/frontend/package.json b/apps/frontend/package.json index b0c870d..e74cd24 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-tooltip": "1.1.6", "@tanstack/react-query": "5.62.7", "@tanstack/react-table": "8.20.5", + "@xyflow/react": "^12.11.5", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "lucide-react": "0.469.0", diff --git a/apps/frontend/src/app/app/telefonia/ivr/actions.ts b/apps/frontend/src/app/app/telefonia/ivr/actions.ts index 8ddd09a..a70b847 100644 --- a/apps/frontend/src/app/app/telefonia/ivr/actions.ts +++ b/apps/frontend/src/app/app/telefonia/ivr/actions.ts @@ -43,6 +43,23 @@ export async function createIvrMenu(input: CreateIvrMenuInput): Promise<{ ok: tr } } +export async function updateIvrMenuOptions( + id: string, + options: IvrMenuOptionInput[], +): Promise<{ ok: true; menu: IvrMenu } | { ok: false; error: string }> { + const session = await requireSession(); + try { + const menu = await apiFetch(`/ivr-menus/${id}`, session.accessToken, { + method: "PATCH", + body: JSON.stringify({ options }), + }); + revalidatePath("/app/telefonia/ivr"); + return { ok: true, menu }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} + export async function deleteIvrMenu(id: string): Promise<{ ok: true } | { ok: false; error: string }> { const session = await requireSession(); try { diff --git a/apps/frontend/src/app/app/telefonia/ivr/ivr-flow-editor.tsx b/apps/frontend/src/app/app/telefonia/ivr/ivr-flow-editor.tsx new file mode 100644 index 0000000..7ef782e --- /dev/null +++ b/apps/frontend/src/app/app/telefonia/ivr/ivr-flow-editor.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { + ReactFlow, + Background, + Controls, + Handle, + Position, + MarkerType, + useNodesState, + useEdgesState, + type Node, + type Edge, + type NodeProps, + type NodeTypes, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { PhoneIncoming, Plus, Save, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input, Select } from "@/components/ui/input"; +import { ALLOWED_IVR_DIGITS, type IvrMenu } from "@/lib/callcenter-types"; +import type { Extension } from "@/lib/extension-types"; +import { updateIvrMenuOptions, type IvrMenuOptionInput } from "./actions"; + +interface OptionState { + key: string; + digit: string; + destinationNumber: string; + label: string; +} + +interface EntryNodeData extends Record {} + +interface OptionNodeData extends Record { + option: OptionState; + extensions: Extension[]; + usedDigits: string[]; + onChange: (key: string, patch: Partial) => void; + onRemove: (key: string) => void; +} + +function EntryNode(_props: NodeProps>) { + return ( +
+
+ + Entrada do menu +
+

Atende, toca o prompt e aguarda 1 dígito

+ +
+ ); +} + +function OptionNode({ data }: NodeProps>) { + const { option, extensions, usedDigits, onChange, onRemove } = data; + return ( +
+ +
+ + +
+ + onChange(option.key, { label: e.target.value })} + placeholder="Descrição (opcional)" + /> +
+ ); +} + +const NODE_TYPES: NodeTypes = { entry: EntryNode, option: OptionNode }; + +const ENTRY_NODE_ID = "entry"; +const COLUMN_WIDTH = 260; + +function layoutNodes(options: OptionState[], extensions: Extension[], onChange: OptionNodeData["onChange"], onRemove: OptionNodeData["onRemove"]) { + const usedDigits = options.map((o) => o.digit); + const entry: Node = { + id: ENTRY_NODE_ID, + type: "entry", + position: { x: (Math.max(options.length, 1) * COLUMN_WIDTH) / 2 - 85, y: 0 }, + data: {}, + draggable: true, + }; + const optionNodes: Node[] = options.map((option, i) => ({ + id: option.key, + type: "option", + position: { x: i * COLUMN_WIDTH, y: 160 }, + data: { option, extensions, usedDigits, onChange, onRemove }, + draggable: true, + })); + const edges: Edge[] = options.map((option) => ({ + id: `${ENTRY_NODE_ID}-${option.key}`, + source: ENTRY_NODE_ID, + target: option.key, + markerEnd: { type: MarkerType.ArrowClosed }, + })); + return { nodes: [entry, ...optionNodes], edges }; +} + +let keyCounter = 0; +function nextKey(): string { + keyCounter += 1; + return `opt-${Date.now()}-${keyCounter}`; +} + +/** + * Editor visual do menu de IVR (PHASE 60, pedido do usuário: "ajusta o + * IVR para fazer o fluxo de maneira visual") — canvas de nós/arestas em + * cima do MESMO modelo que já existia (entrada + opções por dígito), + * sem nenhuma dependência de execução externa. Cada nó de opção edita + * direto os campos (dígito/ramal/descrição); salvar recompila o + * dialplan no backend exatamente como o formulário antigo fazia. + */ +export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions: Extension[] }) { + const initialOptions = useMemo( + () => menu.options.map((o) => ({ key: o.id, digit: o.digit, destinationNumber: o.destinationNumber, label: o.label ?? "" })), + [menu.options], + ); + const [options, setOptions] = useState(initialOptions); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + const [pending, setPending] = useState(false); + + const onChange = useCallback((key: string, patch: Partial) => { + setSaved(false); + setOptions((prev) => prev.map((o) => (o.key === key ? { ...o, ...patch } : o))); + }, []); + + const onRemove = useCallback((key: string) => { + setSaved(false); + setOptions((prev) => prev.filter((o) => o.key !== key)); + }, []); + + const { nodes: initialNodes, edges: initialEdges } = useMemo( + () => layoutNodes(options, extensions, onChange, onRemove), + [options, extensions, onChange, onRemove], + ); + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + + // useNodesState/useEdgesState só inicializam uma vez — recalcula o + // grafo sempre que a LISTA de opções muda (add/remove/digit editado + // afeta layout e handles), preservando posição arrastada manualmente + // quando só o conteúdo de um nó já existente muda. + useMemo(() => { + setNodes((prevNodes) => { + const positions = new Map(prevNodes.map((n) => [n.id, n.position])); + return initialNodes.map((n) => ({ ...n, position: positions.get(n.id) ?? n.position })); + }); + setEdges(initialEdges); + // eslint-disable-next-line react-hooks/exhaustive-deps -- só quando initialNodes/Edges mudam de identidade (opções mudaram) + }, [initialNodes, initialEdges]); + + function addOption() { + setSaved(false); + const usedDigits = new Set(options.map((o) => o.digit)); + const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1"; + setOptions((prev) => [...prev, { key: nextKey(), digit: nextDigit, destinationNumber: "", label: "" }]); + } + + function onSave() { + setError(null); + if (options.length === 0) { + setError("O menu precisa de pelo menos uma opção."); + return; + } + if (options.some((o) => !o.destinationNumber.trim())) { + setError("Toda opção precisa de um ramal de destino selecionado."); + return; + } + const digits = options.map((o) => o.digit); + if (new Set(digits).size !== digits.length) { + setError("Não pode repetir o mesmo dígito em duas opções."); + return; + } + + const payload: IvrMenuOptionInput[] = options.map((o) => ({ + digit: o.digit, + destinationNumber: o.destinationNumber.trim(), + label: o.label.trim() || undefined, + })); + + setPending(true); + updateIvrMenuOptions(menu.id, payload).then((result) => { + setPending(false); + if (!result.ok) { + setError(result.error); + return; + } + setSaved(true); + }); + } + + return ( +
+
+ +
+ {saved && Salvo} + +
+
+ {error && ( +

+ {error} +

+ )} +
+ + + + +
+
+ ); +} diff --git a/apps/frontend/src/app/app/telefonia/ivr/ivr-view.tsx b/apps/frontend/src/app/app/telefonia/ivr/ivr-view.tsx index b6888d6..394d421 100644 --- a/apps/frontend/src/app/app/telefonia/ivr/ivr-view.tsx +++ b/apps/frontend/src/app/app/telefonia/ivr/ivr-view.tsx @@ -7,10 +7,11 @@ import { Panel, PanelHeader } from "@/components/ui/panel"; import { Button } from "@/components/ui/button"; import { Input, Select, FieldLabel } from "@/components/ui/input"; import { Pill } from "@/components/ui/pill"; -import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table"; +import { EmptyState } from "@/components/ui/table"; import { ALLOWED_IVR_DIGITS, IVR_ENTRY_DESTINATION, type IvrMenu } from "@/lib/callcenter-types"; import type { Extension } from "@/lib/extension-types"; import { createIvrMenu, deleteIvrMenu, deleteIvrMenuPrompt, uploadIvrMenuPrompt, type IvrMenuOptionInput } from "./actions"; +import { IvrFlowEditor } from "./ivr-flow-editor"; const CUSTOM_PROMPT_PREFIX = "/ivr-prompts/"; @@ -69,24 +70,7 @@ export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: E {IVR_ENTRY_DESTINATION}

- - - - - - - - - - {menu.options.map((opt) => ( - - - - - - ))} - -
DígitoDestinoDescrição
{opt.digit}{opt.destinationNumber}{opt.label ?? "—"}
+ ))} diff --git a/docs/INBOUND_ROUTES.md b/docs/INBOUND_ROUTES.md index cf726f8..c132e9c 100644 --- a/docs/INBOUND_ROUTES.md +++ b/docs/INBOUND_ROUTES.md @@ -179,9 +179,34 @@ o DID, `play_and_get_digits` abriu e tocou o arquivo até o fim duas vezes (`mod_sndfile` resample automático, sem erro no log), colheu o dígito real e bridged corretamente com o ramal de destino. +## Editor visual (PHASE 60) + +`apps/frontend/.../telefonia/ivr/ivr-flow-editor.tsx` — canvas de nós e +setas (`@xyflow/react`) em cima do MESMO modelo de sempre (entrada + +opções por dígito), sem nenhuma dependência de execução externa. Um nó +"Entrada" fixo conectado a um nó por opção (dígito + select de ramal + +descrição, editável direto no nó); "Adicionar opção"/"Salvar alterações" +chamam o mesmo `PATCH /ivr-menus/:id` que já existia. Avaliado (e +descartado por enquanto, ver AskUserQuestion desta sessão) integrar +Node-RED de verdade: seria uma plataforma externa completa (motor de +execução próprio, nós "function" = RCE, sem multi-tenancy nativa) — +esforço de vários dias e superfície de risco nova, não um ajuste na tela +atual. Um editor visual próprio, sobre o backend já existente, entrega o +mesmo valor imediato (ver estrutura do menu como grafo, editar visualmente) +sem essas duas desvantagens. + +Testado ponta a ponta pela tela de verdade: menu criado, prompt de voz +real enviado (WAV sintetizado com `espeak-ng`, não um tom sintético), +uma segunda opção adicionada via `PATCH` (mesma chamada que o botão +"Salvar" do editor visual faz) — confirmado no banco que a versão 2 do +dialplan compilou as duas opções corretamente, superando a versão 1. + ## O que falta - Sem TTS (texto→voz) — só upload de arquivo WAV já gravado. +- Editor visual não persiste posição manual dos nós entre recargas + (layout recalculado a cada carga da página — arrastar só ajuda + durante a mesma sessão de edição). - Menu de IVR não suporta sub-menus (uma opção levando a OUTRO IVR) nem destino "fila" — só ramal, dentro do contexto `default`. - Tela de frontend "Rotas de Entrada" cobre só CRUD simples (DID → diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b8958c..ad5467a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -216,6 +216,9 @@ importers: '@tanstack/react-table': specifier: 8.20.5 version: 8.20.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@xyflow/react': + specifier: ^12.11.5 + version: 12.11.5(@types/react-dom@19.1.0(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -1665,6 +1668,9 @@ packages: '@types/d3-delaunay@6.0.1': resolution: {integrity: sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==} + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + '@types/d3-ease@3.0.2': resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} @@ -1677,12 +1683,18 @@ packages: '@types/d3-interpolate@3.0.1': resolution: {integrity: sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==} + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + '@types/d3-path@3.1.1': resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} '@types/d3-scale@4.0.2': resolution: {integrity: sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==} + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + '@types/d3-shape@3.1.7': resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} @@ -1695,6 +1707,12 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} @@ -1759,6 +1777,22 @@ packages: '@visx/vendor@4.0.0-alpha.0': resolution: {integrity: sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==} + '@xyflow/react@12.11.5': + resolution: {integrity: sha512-QqoryGkqEhWBuQN9bZWRKhwr3Uoj9lCGj/tg0NnIWHHEOXV+5c8cYsc7Q9TST63V92lHqcNV9P5cjvJ9ZAmblQ==} + peerDependencies: + '@types/react': '>=17' + '@types/react-dom': '>=17' + react: '>=17' + react-dom: '>=17' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@xyflow/system@0.0.81': + resolution: {integrity: sha512-hfbafW4i7uLq7ILok8QWFFm4KMFw22lbZNJHKfHOMSOOoCk5e5m8yfr84UV9NaJajmogWaLVnp2XFU9JQejlqg==} + abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} @@ -1876,6 +1910,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} @@ -1937,6 +1974,14 @@ packages: resolution: {integrity: sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==} engines: {node: '>=12'} + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + d3-ease@3.0.1: resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} engines: {node: '>=12'} @@ -1961,6 +2006,10 @@ packages: resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} engines: {node: '>=12'} + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + d3-shape@3.2.0: resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} engines: {node: '>=12'} @@ -1977,6 +2026,16 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2952,6 +3011,11 @@ packages: '@types/react': optional: true + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -3011,6 +3075,21 @@ packages: zeptomatch@2.1.0: resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -4189,6 +4268,10 @@ snapshots: '@types/d3-delaunay@6.0.1': {} + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + '@types/d3-ease@3.0.2': {} '@types/d3-format@3.0.1': {} @@ -4201,12 +4284,18 @@ snapshots: dependencies: '@types/d3-color': 3.1.0 + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.0 + '@types/d3-path@3.1.1': {} '@types/d3-scale@4.0.2': dependencies: '@types/d3-time': 3.0.0 + '@types/d3-selection@3.0.11': {} + '@types/d3-shape@3.1.7': dependencies: '@types/d3-path': 3.1.1 @@ -4217,6 +4306,15 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.1 + '@types/d3-selection': 3.0.11 + '@types/geojson@7946.0.16': {} '@types/lodash@4.17.25': {} @@ -4331,6 +4429,31 @@ snapshots: d3-time-format: 4.1.0 internmap: 2.0.3 + '@xyflow/react@12.11.5(@types/react-dom@19.1.0(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@xyflow/system': 0.0.81 + classcat: 5.0.5 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + zustand: 4.5.7(@types/react@19.1.0)(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.0 + '@types/react-dom': 19.1.0(@types/react@19.1.0) + transitivePeerDependencies: + - immer + + '@xyflow/system@0.0.81': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + abstract-logging@2.0.1: {} accepts@1.3.8: @@ -4458,6 +4581,8 @@ snapshots: dependencies: clsx: 2.1.1 + classcat@5.0.5: {} + classnames@2.5.1: {} client-only@0.0.1: {} @@ -4503,6 +4628,13 @@ snapshots: dependencies: delaunator: 5.1.0 + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + d3-ease@3.0.1: {} d3-format@3.1.0: {} @@ -4525,6 +4657,8 @@ snapshots: d3-time: 3.1.0 d3-time-format: 4.1.0 + d3-selection@3.0.0: {} + d3-shape@3.2.0: dependencies: d3-path: 3.1.0 @@ -4539,6 +4673,23 @@ snapshots: d3-timer@3.0.1: {} + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + debug@4.4.3: dependencies: ms: 2.1.3 @@ -5535,6 +5686,10 @@ snapshots: optionalDependencies: '@types/react': 19.1.0 + use-sync-external-store@1.6.0(react@19.1.0): + dependencies: + react: 19.1.0 + util-deprecate@1.0.2: {} valibot@1.4.2(typescript@5.9.3): @@ -5580,3 +5735,10 @@ snapshots: dependencies: grammex: 3.1.13 graphmatch: 1.1.1 + + zustand@4.5.7(@types/react@19.1.0)(react@19.1.0): + dependencies: + use-sync-external-store: 1.6.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.0 + react: 19.1.0