From e1019e96a87922cbc2da48f8805f9c0f6479b0c6 Mon Sep 17 00:00:00 2001 From: "Eunice(Eunyoung) Shin" Date: Thu, 2 Jul 2026 08:47:14 +0900 Subject: [PATCH 1/2] feat: add insight map page with Obsidian-style force-directed graph Add /insight-map route with a d3 force-directed graph (dark theme, click-to-highlight neighbors, dimming) matching the existing UtterAI_AI/docs/ontology_map.html reference design. Add a sidebar tab for direct access and an "insight map" button in ReportChatPanel that opens it scoped to the current report. Pin Node to 24.18.0 via .nvmrc since vite 8 requires Node 20+. --- .gitignore | 1 + .nvmrc | 1 + package-lock.json | 781 +++++++++++++++++++++- package.json | 2 + src/App.tsx | 11 + src/api/insightMap.ts | 47 ++ src/components/common/Icon.tsx | 3 +- src/components/common/ReportChatPanel.tsx | 18 +- src/components/layout/Sidebar.tsx | 1 + src/pages/InsightMapPage.tsx | 438 ++++++++++++ 10 files changed, 1269 insertions(+), 34 deletions(-) create mode 100644 .nvmrc create mode 100644 src/api/insightMap.ts create mode 100644 src/pages/InsightMapPage.tsx diff --git a/.gitignore b/.gitignore index 6bf35f3..72d8068 100644 --- a/.gitignore +++ b/.gitignore @@ -188,3 +188,4 @@ SKILL.md memory/ **/.claude **/.env +.gstack/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..ca5c350 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.18.0 diff --git a/package-lock.json b/package-lock.json index 7c00d38..4c7638d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "axios": "^1.7.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "d3": "^7.9.0", "lucide-react": "^0.453.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -34,6 +35,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/d3": "^7.4.3", "@types/node": "^22.8.4", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.1", @@ -1751,9 +1753,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1771,9 +1770,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1791,9 +1787,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1811,9 +1804,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1831,9 +1821,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1851,9 +1838,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2004,6 +1988,290 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -2018,6 +2286,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2742,6 +3017,416 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2766,6 +3451,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3441,6 +4135,18 @@ "node": ">= 6" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3461,6 +4167,15 @@ "node": ">=0.8.19" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3760,9 +4475,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3784,9 +4496,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3808,9 +4517,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3832,9 +4538,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4639,6 +5342,12 @@ "node": ">=0.10.0" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", @@ -4697,6 +5406,18 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", diff --git a/package.json b/package.json index 051fd9d..19788ac 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "axios": "^1.7.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "d3": "^7.9.0", "lucide-react": "^0.453.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -36,6 +37,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/d3": "^7.4.3", "@types/node": "^22.8.4", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.1", diff --git a/src/App.tsx b/src/App.tsx index 58bb368..c45da08 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import NewSessionPage from '@/pages/NewSessionPage' import ReportsPage from '@/pages/ReportsPage' import TemplatesPage from '@/pages/TemplatesPage' import SettingsPage from '@/pages/SettingsPage' +import InsightMapPage from '@/pages/InsightMapPage' function RequireAuth({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((s) => s.isAuthenticated) @@ -28,6 +29,16 @@ export default function App() { } /> } /> + {/* Standalone window — no AppShell chrome, opened via window.open() */} + + + + } + /> + {/* Protected — shell layout */} +} + +export interface InsightMapEdge { + source: string + target: string + relation: string +} + +export interface InsightMapResponse { + nodes: InsightMapNode[] + edges: InsightMapEdge[] +} + +export interface SourceLinkResponse { + url: string +} + +export const DEFAULT_INSIGHT_MAP_MODE: InsightMapMode[] = ['research', 'current_patient', 'my_soap'] + +export const insightMapApi = { + get: (reportDraftId: string | null, mode: InsightMapMode[] = DEFAULT_INSIGHT_MAP_MODE) => + apiClient.get('/insight-map', { + params: { report_draft_id: reportDraftId ?? undefined, mode }, + paramsSerializer: { indexes: null }, + }), + + search: (query: string, reportDraftId: string | null, mode: InsightMapMode[] = DEFAULT_INSIGHT_MAP_MODE) => + apiClient.post('/insight-map/search', { + query, + report_draft_id: reportDraftId, + mode, + }), + + getSourceLink: (caseIndexId: string) => + apiClient.get(`/soap-case-indexes/${caseIndexId}/source-link`), +} diff --git a/src/components/common/Icon.tsx b/src/components/common/Icon.tsx index 64ce0fa..bfc0566 100644 --- a/src/components/common/Icon.tsx +++ b/src/components/common/Icon.tsx @@ -3,7 +3,7 @@ import { Bell, Search, Plus, Trash2, Mail, Lock, User, ShieldCheck, Check, AlertCircle, Info, LogOut, ArrowLeft, Download, Upload, RefreshCw, Eye, EyeOff, Filter, X, Edit3, - ClipboardList, BarChart2, SendHorizonal, type LucideProps, + ClipboardList, BarChart2, SendHorizonal, Network, type LucideProps, } from 'lucide-react' const icons = { @@ -37,6 +37,7 @@ const icons = { edit: Edit3, clipboard: ClipboardList, send: SendHorizonal, + network: Network, } type IconName = keyof typeof icons diff --git a/src/components/common/ReportChatPanel.tsx b/src/components/common/ReportChatPanel.tsx index e078dcf..13d9def 100644 --- a/src/components/common/ReportChatPanel.tsx +++ b/src/components/common/ReportChatPanel.tsx @@ -103,9 +103,21 @@ export function ReportChatPanel({ report, onPatchApplied }: Props) { return (
{/* header */} -
-

AI 보조

-

수정 제안을 요청하거나 근거를 확인하세요

+
+
+

AI 보조

+

수정 제안을 요청하거나 근거를 확인하세요

+
+
{/* messages */} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 819c134..5cf3801 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -10,6 +10,7 @@ const NAV_ITEMS = [ { to: '/sessions', label: '세션 관리', icon: 'audio' as const }, { to: '/reports', label: '리포트', icon: 'report' as const }, { to: '/templates', label: '템플릿 관리', icon: 'fileText' as const }, + { to: '/insight-map', label: '인사이트맵', icon: 'network' as const }, ] const UtterWordmark = () => ( diff --git a/src/pages/InsightMapPage.tsx b/src/pages/InsightMapPage.tsx new file mode 100644 index 0000000..a8904f0 --- /dev/null +++ b/src/pages/InsightMapPage.tsx @@ -0,0 +1,438 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import * as d3 from 'd3' +import { + insightMapApi, + DEFAULT_INSIGHT_MAP_MODE, + type InsightMapMode, + type InsightMapNode, + type InsightMapResponse, +} from '@/api/insightMap' +import { Icon } from '@/components/common/Icon' +import { useToast } from '@/hooks/useToast' +import { cn } from '@/lib/utils' + +const MODE_OPTIONS: { value: InsightMapMode; label: string }[] = [ + { value: 'research', label: '연구 근거 중심' }, + { value: 'current_patient', label: '현재 환자 히스토리 포함' }, + { value: 'my_soap', label: '내 SOAP 기록 포함' }, +] + +// Obsidian 그래프 뷰 / UtterAI_AI/docs/ontology_map.html과 동일한 팔레트(Catppuccin Mocha) +const LAYER_COLOR: Record = { + ontology: '#89b4fa', + current_patient: '#a6e3a1', + my_soap: '#fab387', +} +const LAYER_LABEL: Record = { + ontology: '연구 근거 (개념)', + current_patient: '현재 환자 히스토리', + my_soap: '내 SOAP 기록', +} +const LAYER_RADIUS: Record = { + ontology: 16, + current_patient: 13, + my_soap: 13, +} + +interface SimNode extends d3.SimulationNodeDatum, InsightMapNode {} +interface SimLink extends d3.SimulationLinkDatum { + relation: string +} + +export default function InsightMapPage() { + const [searchParams] = useSearchParams() + const reportId = searchParams.get('report_id') ?? searchParams.get('report_draft_id') + const initialFocus = searchParams.get('focus') ?? '' + // 사이드바에서 리포트 맥락 없이 직접 들어온 경우 — 연구 근거 개념만 둘러볼 수 있다. + // 환자 히스토리/내 SOAP 기록은 특정 리포트에 묶여 있어 이 모드에서는 의미가 없다. + const isGeneralMode = !reportId + + const { showToast } = useToast() + const [mode, setMode] = useState( + isGeneralMode ? ['research'] : DEFAULT_INSIGHT_MAP_MODE, + ) + const [query, setQuery] = useState(initialFocus) + const [graph, setGraph] = useState({ nodes: [], edges: [] }) + const [loading, setLoading] = useState(false) + const [selectedNode, setSelectedNode] = useState(null) + + const svgRef = useRef(null) + const containerRef = useRef(null) + + const loadDefault = useCallback(async () => { + setLoading(true) + try { + const { data } = await insightMapApi.get(reportId, mode) + setGraph(data) + } catch { + showToast({ title: '인사이트맵을 불러오지 못했습니다', kind: 'error' }) + } finally { + setLoading(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reportId, mode]) + + const handleSearch = useCallback( + async (q: string) => { + if (!q.trim()) { + loadDefault() + return + } + setLoading(true) + try { + const { data } = await insightMapApi.search(q.trim(), reportId, mode) + setGraph(data) + } catch { + showToast({ title: '검색에 실패했습니다', kind: 'error' }) + } finally { + setLoading(false) + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [reportId, mode], + ) + + const toggleMode = (value: InsightMapMode) => { + setMode((prev) => + prev.includes(value) ? prev.filter((m) => m !== value) : [...prev, value], + ) + } + + // reportId 또는 mode(연구근거/환자히스토리/내SOAP기록 토글)가 바뀔 때마다 + // 현재 검색어 기준으로 다시 불러온다. query는 의도적으로 deps에서 제외 — + // 매 keystroke가 아니라 Enter/토글 시점에만 재조회한다. + useEffect(() => { + if (query.trim()) { + handleSearch(query) + } else { + loadDefault() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reportId, mode]) + + const handleViewSoapNote = async (node: InsightMapNode) => { + const caseIndexId = node.id.replace(/^caseidx_/, '') + try { + const { data } = await insightMapApi.getSourceLink(caseIndexId) + window.open(data.url, '_blank', 'noopener,noreferrer') + } catch { + showToast({ title: 'SOAP note를 열 수 없습니다', kind: 'error' }) + } + } + + // ── Obsidian 스타일 force-directed 그래프 렌더링 ──────────────────────────── + useEffect(() => { + const svgEl = svgRef.current + const container = containerRef.current + if (!svgEl || !container) return + + const width = container.clientWidth + const height = container.clientHeight + + const svg = d3.select(svgEl).attr('width', width).attr('height', height) + svg.selectAll('*').remove() + + const g = svg.append('g') + + svg.call( + d3.zoom() + .scaleExtent([0.2, 4]) + .on('zoom', (e) => g.attr('transform', e.transform.toString())), + ) + + const defs = svg.append('defs') + Object.entries(LAYER_COLOR).forEach(([layer, color]) => { + defs + .append('marker') + .attr('id', `arrow-${layer}`) + .attr('viewBox', '0 -5 10 10') + .attr('refX', 18) + .attr('refY', 0) + .attr('markerWidth', 6) + .attr('markerHeight', 6) + .attr('orient', 'auto') + .append('path') + .attr('d', 'M0,-5L10,0L0,5') + .attr('fill', color) + .attr('opacity', 0.5) + }) + + const nodeById = new Map() + const simNodes: SimNode[] = graph.nodes.map((n) => { + const node = { ...n } as SimNode + nodeById.set(n.id, node) + return node + }) + const simLinks: SimLink[] = graph.edges + .filter((e) => nodeById.has(e.source) && nodeById.has(e.target)) + .map((e) => ({ source: e.source, target: e.target, relation: e.relation })) + + const sim = d3 + .forceSimulation(simNodes) + .force( + 'link', + d3.forceLink(simLinks).id((d) => d.id).distance(120).strength(0.4), + ) + .force('charge', d3.forceManyBody().strength(-380)) + .force('center', d3.forceCenter(width / 2, height / 2)) + .force('collision', d3.forceCollide((d) => (LAYER_RADIUS[(d as SimNode).source_layer] ?? 12) + 18)) + + const linkSel = g + .append('g') + .selectAll('line') + .data(simLinks) + .join('line') + .attr('stroke', '#313244') + .attr('stroke-width', 1.5) + .attr('marker-end', (d) => { + const source = typeof d.source === 'object' ? (d.source as SimNode) : nodeById.get(d.source as string) + return `url(#arrow-${source?.source_layer ?? 'ontology'})` + }) + + const edgeLabelSel = g + .append('g') + .selectAll('text') + .data(simLinks) + .join('text') + .attr('font-size', 9) + .attr('fill', '#45475a') + .attr('pointer-events', 'none') + .text((d) => d.relation) + + const nodeSel = g + .append('g') + .selectAll('g') + .data(simNodes) + .join('g') + .style('cursor', 'pointer') + .call( + d3 + .drag() + .on('start', (e, d) => { + if (!e.active) sim.alphaTarget(0.3).restart() + d.fx = d.x + d.fy = d.y + }) + .on('drag', (e, d) => { + d.fx = e.x + d.fy = e.y + }) + .on('end', (e, d) => { + if (!e.active) sim.alphaTarget(0) + d.fx = null + d.fy = null + }), + ) + + nodeSel + .append('circle') + .attr('r', (d) => LAYER_RADIUS[d.source_layer] ?? 12) + .attr('fill', (d) => (LAYER_COLOR[d.source_layer] ?? '#89b4fa') + '22') + .attr('stroke', (d) => LAYER_COLOR[d.source_layer] ?? '#89b4fa') + .attr('stroke-width', 2) + .style('transition', 'filter 0.2s, stroke 0.2s') + + nodeSel + .append('text') + .attr('dy', (d) => (LAYER_RADIUS[d.source_layer] ?? 12) + 13) + .attr('text-anchor', 'middle') + .attr('font-size', 11) + .attr('fill', '#cdd6f4') + .style('text-shadow', '0 1px 4px #0f1117, 0 0 8px #0f1117') + .style('pointer-events', 'none') + .text((d) => d.label) + + function neighborsOf(d: SimNode) { + const ids = new Set([d.id]) + const linkIdx = new Set() + simLinks.forEach((l, i) => { + const s = typeof l.source === 'object' ? (l.source as SimNode).id : (l.source as string) + const t = typeof l.target === 'object' ? (l.target as SimNode).id : (l.target as string) + if (s === d.id || t === d.id) { + ids.add(s) + ids.add(t) + linkIdx.add(i) + } + }) + return { ids, linkIdx } + } + + nodeSel + .on('click', (e, d) => { + e.stopPropagation() + const { ids, linkIdx } = neighborsOf(d) + nodeSel.select('circle').attr('opacity', (n) => (ids.has(n.id) ? 1 : 0.2)) + nodeSel.select('text').attr('opacity', (n) => (ids.has(n.id) ? 1 : 0.15)) + linkSel.attr('stroke', (_, i) => (linkIdx.has(i) ? '#89b4fa' : '#313244')) + linkSel.attr('stroke-width', (_, i) => (linkIdx.has(i) ? 2.5 : 1.5)) + setSelectedNode(d) + }) + .on('mouseover', function () { + d3.select(this).select('circle').style('filter', 'drop-shadow(0 0 8px currentColor)') + }) + .on('mouseout', function () { + d3.select(this).select('circle').style('filter', null) + }) + + svg.on('click', () => { + nodeSel.select('circle').attr('opacity', 1) + nodeSel.select('text').attr('opacity', 1) + linkSel.attr('stroke', '#313244').attr('stroke-width', 1.5) + }) + + sim.on('tick', () => { + linkSel + .attr('x1', (d) => (d.source as SimNode).x ?? 0) + .attr('y1', (d) => (d.source as SimNode).y ?? 0) + .attr('x2', (d) => (d.target as SimNode).x ?? 0) + .attr('y2', (d) => (d.target as SimNode).y ?? 0) + + edgeLabelSel + .attr('x', (d) => (((d.source as SimNode).x ?? 0) + ((d.target as SimNode).x ?? 0)) / 2) + .attr('y', (d) => (((d.source as SimNode).y ?? 0) + ((d.target as SimNode).y ?? 0)) / 2) + + nodeSel.attr('transform', (d) => `translate(${d.x ?? 0},${d.y ?? 0})`) + }) + + return () => { + sim.stop() + } + }, [graph]) + + const presentLayers = Array.from(new Set(graph.nodes.map((n) => n.source_layer))) + + return ( +
+
+ {/* header overlay */} +
+ +

근거 기반 임상 인사이트맵

+ +
+
+ + setQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch(query)} + placeholder="예: 낮은 MLU, 짧은 발화" + className="flex-1 bg-transparent text-[12px] outline-none placeholder-[#6c7086] text-[#cdd6f4]" + /> +
+
+ +
+ {MODE_OPTIONS.map((opt) => { + const disabled = isGeneralMode && opt.value !== 'research' + return ( + + ) + })} +
+
+ + {isGeneralMode && ( +

+ 리포트 상세에서 열면 현재 환자 히스토리와 내 SOAP 기록도 함께 볼 수 있어요. +

+ )} + + {/* legend */} +
+

데이터 범위

+ {(presentLayers.length ? presentLayers : ['ontology']).map((layer) => ( +
+
+ {LAYER_LABEL[layer] ?? layer} +
+ ))} +
+ + {/* hint */} +
+ 스크롤: 줌 | 드래그: 이동 | 노드 클릭: 연결 강조 +
+ 배경 클릭: 초기화 +
+ + {loading && ( +
+ 불러오는 중… +
+ )} + + {!loading && graph.nodes.length === 0 && ( +
+ 표시할 개념이 없습니다. +
+ )} + + +
+ + {/* side panel */} + {selectedNode && ( +
+
+

{selectedNode.label}

+ +
+ + {selectedNode.type} + + + {selectedNode.summary && ( +

{selectedNode.summary}

+ )} + + {selectedNode.type === 'patient_metric_trend' && ( +
+              {JSON.stringify(selectedNode.data.trend, null, 2)}
+            
+ )} + + {selectedNode.type === 'soap_case_index' && ( + + )} +
+ )} +
+ ) +} From adc2429cbe02e9e6d9adbaa525af8640abb23c42 Mon Sep 17 00:00:00 2001 From: "Eunice(Eunyoung) Shin" Date: Thu, 2 Jul 2026 10:30:51 +0900 Subject: [PATCH 2/2] feat: embed insight map in app shell, add literature evidence, fix lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move /insight-map inside the authenticated AppShell layout so the sidebar stays visible instead of taking over the whole screen. Add a "문헌 근거 보기" section to the concept side panel that fetches related paper/guide citations (with DOI links when available) from the new BE endpoint. Enable "내 SOAP 기록 포함" in general (no report_id) mode since it now works across the therapist's full caseload. Remove three unused eslint-disable directives that were failing --max-warnings 0. --- src/App.tsx | 11 +--- src/api/insightMap.ts | 14 +++++ src/components/layout/AppShell.tsx | 2 +- src/pages/InsightMapPage.tsx | 86 ++++++++++++++++++++++++++---- 4 files changed, 91 insertions(+), 22 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index c45da08..25c3a16 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,16 +29,6 @@ export default function App() { } /> } /> - {/* Standalone window — no AppShell chrome, opened via window.open() */} - - - - } - /> - {/* Protected — shell layout */} } /> } /> } /> + } /> } /> diff --git a/src/api/insightMap.ts b/src/api/insightMap.ts index 73ce5d3..c5aed90 100644 --- a/src/api/insightMap.ts +++ b/src/api/insightMap.ts @@ -26,6 +26,17 @@ export interface SourceLinkResponse { url: string } +export interface EvidenceItem { + title: string + content: string + score: number + doi_url: string | null +} + +export interface EvidenceResponse { + evidence: EvidenceItem[] +} + export const DEFAULT_INSIGHT_MAP_MODE: InsightMapMode[] = ['research', 'current_patient', 'my_soap'] export const insightMapApi = { @@ -44,4 +55,7 @@ export const insightMapApi = { getSourceLink: (caseIndexId: string) => apiClient.get(`/soap-case-indexes/${caseIndexId}/source-link`), + + getConceptEvidence: (conceptKey: string) => + apiClient.get(`/insight-map/concepts/${conceptKey}/evidence`), } diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index dac0c88..686ea48 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -25,7 +25,7 @@ export function AppShell() { {/* Page content */} -
+
diff --git a/src/pages/InsightMapPage.tsx b/src/pages/InsightMapPage.tsx index a8904f0..d33f7d1 100644 --- a/src/pages/InsightMapPage.tsx +++ b/src/pages/InsightMapPage.tsx @@ -4,6 +4,7 @@ import * as d3 from 'd3' import { insightMapApi, DEFAULT_INSIGHT_MAP_MODE, + type EvidenceItem, type InsightMapMode, type InsightMapNode, type InsightMapResponse, @@ -44,22 +45,44 @@ export default function InsightMapPage() { const [searchParams] = useSearchParams() const reportId = searchParams.get('report_id') ?? searchParams.get('report_draft_id') const initialFocus = searchParams.get('focus') ?? '' - // 사이드바에서 리포트 맥락 없이 직접 들어온 경우 — 연구 근거 개념만 둘러볼 수 있다. - // 환자 히스토리/내 SOAP 기록은 특정 리포트에 묶여 있어 이 모드에서는 의미가 없다. + // 사이드바에서 리포트 맥락 없이 직접 들어온 경우 — 현재 환자 히스토리(지표 추이)는 + // 특정 환자에 묶여 있어 이 모드에서는 의미가 없다. 내 SOAP 기록은 담당 환자 전체를 + // 대상으로 둘러볼 수 있으므로 계속 사용 가능하다. const isGeneralMode = !reportId const { showToast } = useToast() const [mode, setMode] = useState( - isGeneralMode ? ['research'] : DEFAULT_INSIGHT_MAP_MODE, + isGeneralMode ? ['research', 'my_soap'] : DEFAULT_INSIGHT_MAP_MODE, ) const [query, setQuery] = useState(initialFocus) const [graph, setGraph] = useState({ nodes: [], edges: [] }) const [loading, setLoading] = useState(false) const [selectedNode, setSelectedNode] = useState(null) + const [evidence, setEvidence] = useState(null) + const [evidenceLoading, setEvidenceLoading] = useState(false) const svgRef = useRef(null) const containerRef = useRef(null) + const selectNode = (node: InsightMapNode | null) => { + setSelectedNode(node) + setEvidence(null) + } + + const handleLoadEvidence = async (node: InsightMapNode) => { + const conceptKey = node.id.replace(/^concept_/, '') + setEvidenceLoading(true) + try { + const { data } = await insightMapApi.getConceptEvidence(conceptKey) + setEvidence(data.evidence) + } catch { + showToast({ title: '문헌 근거를 불러오지 못했습니다', kind: 'error' }) + setEvidence([]) + } finally { + setEvidenceLoading(false) + } + } + const loadDefault = useCallback(async () => { setLoading(true) try { @@ -70,7 +93,6 @@ export default function InsightMapPage() { } finally { setLoading(false) } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [reportId, mode]) const handleSearch = useCallback( @@ -89,7 +111,6 @@ export default function InsightMapPage() { setLoading(false) } }, - // eslint-disable-next-line react-hooks/exhaustive-deps [reportId, mode], ) @@ -108,7 +129,6 @@ export default function InsightMapPage() { } else { loadDefault() } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [reportId, mode]) const handleViewSoapNote = async (node: InsightMapNode) => { @@ -266,7 +286,7 @@ export default function InsightMapPage() { nodeSel.select('text').attr('opacity', (n) => (ids.has(n.id) ? 1 : 0.15)) linkSel.attr('stroke', (_, i) => (linkIdx.has(i) ? '#89b4fa' : '#313244')) linkSel.attr('stroke-width', (_, i) => (linkIdx.has(i) ? 2.5 : 1.5)) - setSelectedNode(d) + selectNode(d) }) .on('mouseover', function () { d3.select(this).select('circle').style('filter', 'drop-shadow(0 0 8px currentColor)') @@ -303,7 +323,7 @@ export default function InsightMapPage() { const presentLayers = Array.from(new Set(graph.nodes.map((n) => n.source_layer))) return ( -
+
{/* header overlay */}
@@ -325,7 +345,7 @@ export default function InsightMapPage() {
{MODE_OPTIONS.map((opt) => { - const disabled = isGeneralMode && opt.value !== 'research' + const disabled = isGeneralMode && opt.value === 'current_patient' return (