
diff --git a/src/components/studio/ui/overlay.tsx b/src/components/studio/ui/overlay.tsx
index c425f2ad7..934d96d96 100644
--- a/src/components/studio/ui/overlay.tsx
+++ b/src/components/studio/ui/overlay.tsx
@@ -206,7 +206,7 @@ export const Overlay = ({
diff --git a/src/components/studio/views/graph-view.tsx b/src/components/studio/views/graph-view.tsx
index 590b2a98b..e9664f2cf 100644
--- a/src/components/studio/views/graph-view.tsx
+++ b/src/components/studio/views/graph-view.tsx
@@ -2,7 +2,7 @@
import { useStudioStore } from '@/lib/studio/store'
import { seedGraph } from '@/lib/studio/seed-data'
-import { placeGraphNodes } from '@/lib/studio/graph-layout'
+import { placeGraphNodes, computeCircularLayout } from '@/lib/studio/graph-layout'
import { canvasSize, canvasViewBox } from '@/lib/studio/graph-viewport'
import { getEntityTypeDefs, getEntityTypeMeta } from '@/lib/studio/entity-types'
import { translate as entityTypesT } from '@/lib/i18n/messages/entity-types'
@@ -23,6 +23,10 @@ import {
type GraphLayout,
} from '@/lib/studio/graph-snapshot'
import { FOCUS_MODE_FILTER_STYLE, GraphEdgeElement } from './graph-elements'
+import { truncateGraphemes } from '@/lib/text'
+
+/** Maximum character/grapheme count for node labels before truncation. */
+export const GRAPH_LABEL_MAX = 24
/** Interactive knowledge graph view with force, circular, and hierarchical layouts. */
export const GraphView = () => {
@@ -68,12 +72,8 @@ export const GraphView = () => {
// Apply layout transforms
const positioned = useMemo(() => {
if (layout === 'circular') {
- const cx = 400, cy = 280, r = 200
- return nodes.map((n, i) => ({
- ...n,
- x: cx + r * Math.cos((2 * Math.PI * i) / nodes.length),
- y: cy + r * Math.sin((2 * Math.PI * i) / nodes.length),
- }))
+ const baseCanvas = canvasSize(nodes)
+ return computeCircularLayout(nodes, baseCanvas)
}
if (layout === 'hierarchical') {
const cols = Math.ceil(Math.sqrt(nodes.length))
@@ -384,7 +384,7 @@ const handleGraphKeyDown = useCallback(
isSelected ? 'fill-ink' : 'fill-ink-soft',
)}
>
- {n.label.length > 24 ? `${n.label.slice(0, 22)}β¦` : n.label}
+ {truncateGraphemes(n.label, GRAPH_LABEL_MAX)}
diff --git a/src/components/studio/views/triz-matrix-view.tsx b/src/components/studio/views/triz-matrix-view.tsx
index aba7cf737..c4d81a20c 100644
--- a/src/components/studio/views/triz-matrix-view.tsx
+++ b/src/components/studio/views/triz-matrix-view.tsx
@@ -9,6 +9,10 @@ import { TextInput } from '../ui/shared-primitives'
import { useReducedMotion } from '@/lib/studio/use-reduced-motion'
import { filterParams } from './triz-view-utils'
import { translate } from '@/lib/i18n/messages/triz'
+import { truncateGraphemes } from '@/lib/text'
+
+/** Maximum character/grapheme count for TRIZ row labels before truncation. */
+export const TRIZ_LABEL_MAX = 12
type FilteredParameter = ReturnType[number]
@@ -88,7 +92,7 @@ const MatrixRow = ({
title={rowLabel}
>
{rowIndex + 1}
- {rowLabel.length > 12 ? `${rowLabel.slice(0, 12)}β¦` : rowLabel}
+ {truncateGraphemes(rowLabel, TRIZ_LABEL_MAX)}
{filtered.map(({ index: colIndex, label: colLabel }) => {
const key = `${rowIndex}-${colIndex}`
diff --git a/src/components/ui/navigation-menu.tsx b/src/components/ui/navigation-menu.tsx
index 7a91cdfd8..62f62e3e0 100755
--- a/src/components/ui/navigation-menu.tsx
+++ b/src/components/ui/navigation-menu.tsx
@@ -154,7 +154,7 @@ function NavigationMenuIndicator({
{
})
})
+describe('computeCircularLayout', () => {
+ it('returns empty array when given no nodes', () => {
+ expect(computeCircularLayout([], { width: 800, height: 560 })).toEqual([])
+ })
+
+ it('centers nodes on the canvas center for any canvas size', () => {
+ const testNodes: GraphNode[] = [
+ { id: '1', label: 'N1', type: 'concept', x: 0, y: 0 },
+ { id: '2', label: 'N2', type: 'concept', x: 0, y: 0 },
+ { id: '3', label: 'N3', type: 'concept', x: 0, y: 0 },
+ { id: '4', label: 'N4', type: 'concept', x: 0, y: 0 },
+ ]
+
+ const canvas = { width: 1000, height: 600 }
+ const circular = computeCircularLayout(testNodes, canvas)
+
+ const expectedCx = 1000 / 2
+ const expectedCy = 600 / 2
+ const expectedR = Math.min(expectedCx, expectedCy) * 0.7
+
+ // Average position of nodes on a full circle should equal (cx, cy)
+ const avgX = circular.reduce((sum, n) => sum + n.x, 0) / circular.length
+ const avgY = circular.reduce((sum, n) => sum + n.y, 0) / circular.length
+
+ expect(avgX).toBeCloseTo(expectedCx, 5)
+ expect(avgY).toBeCloseTo(expectedCy, 5)
+
+ // Distance of each node from center should equal expectedR
+ for (const node of circular) {
+ const dist = Math.hypot(node.x - expectedCx, node.y - expectedCy)
+ expect(dist).toBeCloseTo(expectedR, 5)
+ }
+ })
+})
+
describe('placeGraphNodes', () => {
it('keeps authored seed positions', () => {
const seedNode = seedGraph.nodes[0] as GraphNode
diff --git a/src/lib/studio/graph-layout.ts b/src/lib/studio/graph-layout.ts
index fb29e4b9b..91b3199ec 100644
--- a/src/lib/studio/graph-layout.ts
+++ b/src/lib/studio/graph-layout.ts
@@ -204,6 +204,26 @@ const byEntityId = (a: Entity, b: Entity): number => a.id.localeCompare(b.id)
* are placed in grows with their count, so a large library spreads out instead of
* crowding into sub-click-safe gaps (see `placementBand`).
*/
+/**
+ * Computes circular layout node positions centered at (canvas.width / 2, canvas.height / 2)
+ * with radius derived from Math.min(cx, cy) * radiusFactor.
+ */
+export const computeCircularLayout = (
+ nodes: readonly GraphNode[],
+ canvas: { width: number; height: number },
+ radiusFactor = 0.7,
+): GraphNode[] => {
+ if (nodes.length === 0) return []
+ const cx = canvas.width / 2
+ const cy = canvas.height / 2
+ const r = Math.min(cx, cy) * radiusFactor
+ return nodes.map((n, i) => ({
+ ...n,
+ x: cx + r * Math.cos((2 * Math.PI * i) / nodes.length),
+ y: cy + r * Math.sin((2 * Math.PI * i) / nodes.length),
+ }))
+}
+
export const placeGraphNodes = (
entities: readonly Entity[],
seedNodes: readonly GraphNode[],
diff --git a/src/lib/text.test.ts b/src/lib/text.test.ts
new file mode 100644
index 000000000..547416368
--- /dev/null
+++ b/src/lib/text.test.ts
@@ -0,0 +1,36 @@
+import { describe, it, expect } from 'vitest'
+import { truncateGraphemes } from './text'
+
+describe('truncateGraphemes', () => {
+ it('returns empty string for falsy input', () => {
+ expect(truncateGraphemes('', 10)).toBe('')
+ })
+
+ it('does not truncate text within maxGraphemes limit', () => {
+ expect(truncateGraphemes('Hello World', 20)).toBe('Hello World')
+ expect(truncateGraphemes('Short', 5)).toBe('Short')
+ })
+
+ it('truncates standard ASCII text when exceeding limit', () => {
+ expect(truncateGraphemes('Hello World Today', 10)).toBe('Hello Worβ¦')
+ })
+
+ it('handles multi-byte and emoji graphemes without splitting surrogates', () => {
+ // π¨βπ©βπ§βπ¦ is 1 grapheme cluster (family emoji)
+ const textWithEmojis = 'π¨βπ©βπ§βπ¦π¨βπ©βπ§βπ¦π¨βπ©βπ§βπ¦π¨βπ©βπ§βπ¦π¨βπ©βπ§βπ¦'
+ const truncated = truncateGraphemes(textWithEmojis, 3)
+ expect(truncated).toBe('π¨βπ©βπ§βπ¦π¨βπ©βπ§βπ¦β¦')
+ })
+
+ it('truncates graph labels (max 24) safely', () => {
+ const longLabel = 'This is a very long graph node label that exceeds limits'
+ const truncated = truncateGraphemes(longLabel, 24)
+ expect(truncated).toBe('This is a very long graβ¦')
+ })
+
+ it('truncates TRIZ labels (max 12) safely', () => {
+ const longParam = 'Temperature / Thermal Expansion'
+ const truncated = truncateGraphemes(longParam, 12)
+ expect(truncated).toBe('Temperatureβ¦')
+ })
+})
diff --git a/src/lib/text.ts b/src/lib/text.ts
new file mode 100644
index 000000000..f55f30fd2
--- /dev/null
+++ b/src/lib/text.ts
@@ -0,0 +1,43 @@
+/**
+ * Grapheme-safe text utilities using Intl.Segmenter.
+ */
+
+let graphemeSegmenter: Intl.Segmenter | null = null
+
+function getGraphemeSegmenter(): Intl.Segmenter | null {
+ if (graphemeSegmenter !== null) return graphemeSegmenter
+ if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
+ try {
+ graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
+ return graphemeSegmenter
+ } catch {
+ return null
+ }
+ }
+ return null
+}
+
+/**
+ * Truncates `text` to at most `maxGraphemes` user-perceived graphemes,
+ * appending `ellipsis` if truncated.
+ */
+export function truncateGraphemes(
+ text: string,
+ maxGraphemes: number,
+ ellipsis = 'β¦',
+): string {
+ if (!text) return ''
+ const segmenter = getGraphemeSegmenter()
+ if (segmenter) {
+ const segments = Array.from(segmenter.segment(text))
+ if (segments.length <= maxGraphemes) return text
+ const cutCount = Math.max(1, maxGraphemes - ellipsis.length)
+ return segments.slice(0, cutCount).map((s) => s.segment).join('') + ellipsis
+ }
+
+ // Fallback for legacy environments without Intl.Segmenter
+ const graphemes = Array.from(text)
+ if (graphemes.length <= maxGraphemes) return text
+ const cutCount = Math.max(1, maxGraphemes - ellipsis.length)
+ return graphemes.slice(0, cutCount).join('') + ellipsis
+}