/** * Shared palette + display-string primitives for any surface that renders * the Neo4j graph. Both the live `/graph` page in platform/ui and the * graph-viewer MCP plugin's PNG renderer import from here so a node looks * the same wherever it appears: same colour family per label, same short * canvas label per node. * * UI-only constants (filter-popover sets, zoom-tier wrap helpers, * agent-action label sets) stay in platform/ui/app/lib/graph-labels.ts and * platform/ui/app/graph/display-helpers.ts — they re-export the symbols * defined here. The two parity tests (this lib's __tests__ and the UI's * unchanged colour-stability tests) keep the surfaces in lockstep. * * Sources of truth: * - Label list: platform/neo4j/schema.cypher (constraints + fulltext * unions); registry doctrine in .docs/graph-label-registries.md * - Brand colour for LocalBusiness: brand.json defaultColors.primary * (resolved at runtime; this module supplies the schema-default fallback) */ /** * Label → display colour mapping. Closed list of node labels written by * the Maxy MCP write paths. A new label without an entry here renders in * FALLBACK_LABEL_COLOUR and is omitted from the legend. */ export const GRAPH_LABEL_COLOURS: Record = { // Business identity — slate-blue family LocalBusiness: '#4F6B8A', Service: '#6B85A0', PriceSpecification: '#8AA0B8', OpeningHoursSpecification: '#A8BACE', // Estate-agent ontology. Listing/Property share the business slate-blue // family; Viewing/Offer are transactional events placed in a warm amber // family distinct from Agent and from the dusty-rose Task/Project/Event // hues so the legend reads them as a separate band. Listing: '#3F5670', Property: '#5C7A99', Viewing: '#D4A574', Offer: '#B08850', // JobLogic service-delivery ontology (Task 1084; supersedes the Task 650/773 // homegrown set). Muted brick-red / earthy family — the service register sits // apart from people-terracotta, viewing-amber, and task-rose bands so // sitedesk legends read as their own group. These are the top-level // (operator-entry) labels; the child labels (QuoteLine, Milestone, Valuation, // VariationNote, JobCost, Contact, PurchaseOrderLine, InvoiceLine, // InvoicePayment, Credit) are coloured in the Task 1965 block appended at the // end of this object, not here. :Engineer is an additional label on Person // and takes the Person colour; :Supplier is an additional label on // Organization and takes the tobacco Organization colour. All hexes here are // pairwise-unique across the registry. Job: '#8C3B2E', Quote: '#A65A4A', Invoice: '#6E4A1F', InboundInvoice: '#6B2A20', WhatsAppGroup: '#D4A096', Customer: '#B5503C', Site: '#C56B4A', Asset: '#9E4A35', Visit: '#C77A4E', Part: '#7A3326', PurchaseOrder: '#B07050', PpmContract: '#8A4A3A', // Job note (Task 1650). A child write-time label attached to its :Job via // (:Note)-[:ABOUT]->(:Job). Not a top-level chip (job-attached, not an // operator-entry entity) but given a colour so it renders on Job drilldown // rather than fallback-grey — same treatment as Message/Section. Steel grey // reads as an annotation, apart from the brick-red service family. Note: '#5E6B73', // External business entity — schema:Organization, written by memory-write // as the Supplier label. Warm tobacco — distinct hue from internal-business // slates so external entities are scannable at a glance. Organization: '#8A6B47', // People — terracotta family (warm, human) Person: '#B86E4A', UserProfile: '#D08960', Preference: '#DFA67A', AdminUser: '#8C5230', AccessGrant: '#66381F', // Knowledge — moss family (organic). :ConversationArchive (Task 397) // sits at the cooler/darker end of the same family so chat transcripts // read as adjacent to documents on the canvas without colliding with // them; the parent label is now the canvas discriminator, not a // property heuristic. KnowledgeDocument: '#6E8A5A', ConversationArchive: '#4F6B3E', Section: '#8AA876', Chunk: '#A6C194', DigitalDocument: '#5A7548', CreativeWork: '#B5C9A2', Question: '#C7D6B5', FAQPage: '#95B385', DefinedTerm: '#76906A', Review: '#3F5A2E', ImageObject: '#A0BB8C', // Conversational — muted plum/lavender family. // Pairwise-distinctness invariants enforced by the UI test // graph-labels.test.ts; UserMessage matches Preference (Person/family cue, // the one intentional duplicate in the registry). Conversation: '#6B5A85', AdminConversation: '#4F4070', PublicConversation: '#8A75A8', Message: '#6B7280', UserMessage: '#DFA67A', AssistantMessage: '#C9A876', ToolCall: '#B5ABCB', // Tasks / projects / events — dusty rose family Task: '#B0617A', Project: '#8E4A60', Event: '#C68095', // Workflows — muted teal Workflow: '#3A6F77', WorkflowStep: '#5A8E96', WorkflowRun: '#7AAAB2', StepResult: '#9CC4CB', // Email — muted moss, hue-adjacent to Knowledge but lower-saturation // and warmer; never co-rendered with KnowledgeDocument. Email: '#6F7F4A', EmailAccount: '#91A063', // Public-agent projection — burnished bronze sits between // people-terracotta and email-moss but shares neither hue. Agent: '#B8893D', // Knowledge-work ontology (the Maxy brand; schema-knowledge-work.md). // Two bands, kept apart from every other family so a knowledge-worker // legend reads them as their own groups. Strategy/decision sits in an // indigo→violet band (distinct from the business slates and the plum // conversational family); Risk is a lone desaturated signal-red placed // away from both bands so it reads as a warning; research/evidence sits // in a teal→green→gold band (distinct from workflow teal and knowledge // moss). All seven hexes are pairwise-unique across the registry. Objective: '#3D5A98', KeyResult: '#5E7BC4', Decision: '#7E60B0', Risk: '#C15B57', Source: '#2F8FA6', Finding: '#4FA389', Hypothesis: '#9A8C3E', // Ledger cash movement (schema-base.md, added by 7b225f814). One movement // of money attached to no invoice — operator-entry with no parent to drill // down from, so it is a top-level chip. Burnished olive-gold sits in the // money register beside Invoice's brown without colliding with it. // // Deliberately appended at the END rather than beside the ledger family: // SHAPE_BY_LABEL assigns shapes by index position in ALL_GRAPH_LABELS // (this object's key order), so a mid-table insert would silently change // the rendered shape of every label after it. CashEntry: '#7D5A2B', // --- Task 1965: colour every node type that can appear in the graph --- // Appended at the END so SHAPE_BY_LABEL indices for the labels above are // unchanged. Grouped by family band; each hex is pairwise-unique across the // whole registry (except the pre-existing UserMessage/Preference twin). // Ledger / money children (brown-gold, beside Invoice and CashEntry). InvoiceLine: '#7A5326', InvoicePayment: '#8A6230', QuoteLine: '#9A7038', PurchaseOrderLine: '#A67E42', JobCost: '#6A4A22', Credit: '#B48A48', Retainer: '#C29A55', Sale: '#8E5E2A', Valuation: '#A06A34', Order: '#B87C3E', DiningOrder: '#C9954E', // Documents / content (steel-blue, hue-adjacent to the business slates). Content: '#58708C', Report: '#6D8299', Page: '#849AAF', Post: '#93A7BA', VariationNote: '#47617B', FileArtifact: '#A9BBCC', // Marketing (bright teal-green, apart from workflow and research teals). SocialPost: '#2FA69A', Campaign: '#3FB8A0', BrandDeal: '#57C2AE', Recommendation: '#74CDBB', // CRM people (terracotta, beside Person and Customer). Contact: '#C97E52', Lead: '#A85E38', Position: '#96522E', Audience: '#E0B088', // Places / transport / retail (forest-emerald, apart from knowledge moss). PostalAddress: '#2E8B57', Room: '#3A9B63', StoreLocation: '#46A96F', Vehicle: '#54B77B', Route: '#62C187', Stop: '#2A7A4C', Shipment: '#6FCB93', Reservation: '#7ED4A0', TableReservation: '#8CDBAC', Menu: '#1F6B41', MenuItem: '#98E0B6', Product: '#34925E', StockMovement: '#4CA574', // Work / delivery (indigo, apart from the strategy indigo band). Milestone: '#4A5FA8', Deliverable: '#6274C0', TimeEntry: '#8189CE', Engagement: '#9AA0DA', Case: '#3B4E92', Meeting: '#5566B4', // Idea (amber). Idea: '#E0A82E', // Web analytics (sky-cyan, apart from the research teal). Session: '#2AA7C4', PageView: '#3DB4CE', Click: '#55C0D6', ScrollMilestone: '#70CCDE', TimelineEvent: '#1E93B0', AnonVisitor: '#88D6E6', AEOAudit: '#46B0CB', // WhatsApp transport (brand green). WhatsAppConversation: '#128C7E', WhatsAppMessage: '#1FA894', // Cloudflare / chain infra (grey-blue). CloudflareHostname: '#5B6E82', CloudflareTunnel: '#6E8296', Chain: '#47586A', ChainLink: '#8496AA', // Voice (violet). VoiceProfile: '#9B59B6', VoiceEdit: '#B07CC9', } export const FALLBACK_LABEL_COLOUR = '#94A3B8' /** * Labels that are an additional label stacked on a base node rather than a * standalone graph node. Each surfaces as its base node's chip and colour, so * none carries a `GRAPH_LABEL_COLOURS` hue of its own. * * Single declaration, two consumers (Task 1893): * - `platform/ui/server/lib/top-level-labels.ts` drops these from the * `/graph` chip allowlist, so a vertical granting one a * `## Top-level node types` row (which the account file-schema projection * needs for its bucket) does not silently add a fallback-grey chip. * - The memory plugin's schema-cypher drift guard exempts these from needing * a `CREATE CONSTRAINT` / `CREATE INDEX` line, because no standalone node * of the label ever exists. * * Membership is read from what the schema docs already state, not decided here: * - `Applicant` on `:Person` (schema-estate-agent.md) * - `Engineer` on `:Person` (schema-construction.md) * - `Supplier` on `:Organization` (schema-construction.md) * - `GuestProfile` on `:Person` (schema-hospitality.md) * * Adding a fifth member is an ontology decision: state it in the vertical's * schema prose first, then add it here. */ export const ADDITIONAL_BASE_LABELS: ReadonlySet = Object.freeze( new Set(['Applicant', 'Engineer', 'GuestProfile', 'Supplier']), ) as ReadonlySet export const ALL_GRAPH_LABELS: readonly string[] = Object.freeze( Object.keys(GRAPH_LABEL_COLOURS) ) /** * Select the sublabel whose colour should drive the node hue. Walks the * label array from index 1 (skipping the base label at index 0) and * returns the first sublabel that has a registered colour; falls back to * labels[0]. Pure — drift-counting lives in resolveNodeColour. */ export function pickDisplayLabel(labels: readonly string[]): string | null { for (let i = 1; i < labels.length; i++) { if (Object.prototype.hasOwnProperty.call(GRAPH_LABEL_COLOURS, labels[i])) { return labels[i] } } return labels[0] ?? null } export function colourForLabel(label: string): string { return GRAPH_LABEL_COLOURS[label] ?? FALLBACK_LABEL_COLOUR } // --------------------------------------------------------------------------- // Shape registry — a second visual channel alongside colour. // // GRAPH_LABEL_COLOURS packs ~60 labels into tight per-family hue bands, so // same-family labels (Person / UserProfile / Preference) are near-identical at // legend-swatch size. Shape breaks that tie: each label gets its own shape, // assigned by cycling this list in the registry's source (family-grouped) // order, so adjacent same-family labels land on different shapes. // // Only shapes that render the caption OUTSIDE the node are allowed. vis-network // draws the label INSIDE box/circle/ellipse/database/text, which would collide // with the wrapped zoom-tier caption system (pickWrappedLabel); those are // excluded. The set is finite (7), so shape is a per-type discriminator that // maximises within-family separation, NOT a global unique key — a family with // more than seven members wraps, and those wrapped pairs stay colour-distinct. export const EXTERNAL_LABEL_SHAPES = [ "dot", "triangle", "square", "diamond", "star", "triangleDown", "hexagon", ] as const export type GraphNodeShape = (typeof EXTERNAL_LABEL_SHAPES)[number] export const FALLBACK_LABEL_SHAPE: GraphNodeShape = "dot" const SHAPE_BY_LABEL: Record = (() => { const out: Record = {} ALL_GRAPH_LABELS.forEach((label, i) => { out[label] = EXTERNAL_LABEL_SHAPES[i % EXTERNAL_LABEL_SHAPES.length] }) return out })() export function shapeForLabel(label: string): GraphNodeShape { return SHAPE_BY_LABEL[label] ?? FALLBACK_LABEL_SHAPE } /** * Node labels written by code with no schema node-type-table row and no * typed-edge declaration. They still render as graph nodes, so they must carry * a colour + glyph like any other node type. The reconciliation gate in * __tests__/icons.test.ts folds this set into the node-label universe. Add a * label here when the runtime `op=icon-fallback` census names one that no * schema table or typed edge declares. */ export const CODE_EMITTED_NODE_LABELS: ReadonlySet = Object.freeze( new Set(['FileArtifact']), ) as ReadonlySet // --------------------------------------------------------------------------- // Icon registry — a third visual channel alongside colour + shape. // // Each label maps to a Lucide glyph so a node's type is legible on the canvas // without consulting the legend. The values are the inner SVG markup of the // Lucide icon (stroke-based paths on a 0 0 24 24 viewBox), extracted from // lucide-react and embedded as literals so this package stays dependency-free // and the browser bundle carries only the ~60 glyphs actually used, not all of // Lucide. One intentional glyph per label in GRAPH_LABEL_COLOURS; the coverage // test in __tests__/icons.test.ts fails if a colour label is added without one. const LABEL_ICONS: Record = { LocalBusiness: '', Service: '', PriceSpecification: '', OpeningHoursSpecification: '', Listing: '', Property: '', Viewing: '', Offer: '', Job: '', Quote: '', Invoice: '', InboundInvoice: '', WhatsAppGroup: '', Customer: '', Site: '', Asset: '', Visit: '', Part: '', PurchaseOrder: '', PpmContract: '', Note: '', Organization: '', Person: '', UserProfile: '', Preference: '', AdminUser: '', AccessGrant: '', KnowledgeDocument: '', ConversationArchive: '', Section: '', Chunk: '', DigitalDocument: '', CreativeWork: '', Question: '', FAQPage: '', DefinedTerm: '', Review: '', ImageObject: '', Conversation: '', AdminConversation: '', PublicConversation: '', Message: '', UserMessage: '', AssistantMessage: '', ToolCall: '', Task: '', Project: '', Event: '', Workflow: '', WorkflowStep: '', WorkflowRun: '', StepResult: '', Email: '', EmailAccount: '', Agent: '', Objective: '', KeyResult: '', Decision: '', Risk: '', Source: '', Finding: '', Hypothesis: '', CashEntry: '', // --- Task 1965: glyphs for the 60 previously-uncovered node labels --- InvoiceLine: '', InvoicePayment: '', QuoteLine: '', PurchaseOrderLine: '', JobCost: '', Credit: '', Retainer: '', Sale: '', Valuation: '', Order: '', DiningOrder: '', Content: '', Report: '', Page: '', Post: '', SocialPost: '', VariationNote: '', Contact: '', Lead: '', Position: '', Audience: '', PostalAddress: '', Room: '', StoreLocation: '', Vehicle: '', Route: '', Stop: '', Shipment: '', Reservation: '', TableReservation: '', Menu: '', MenuItem: '', Product: '', StockMovement: '', Milestone: '', Deliverable: '', TimeEntry: '', Engagement: '', Case: '', Meeting: '', Idea: '', Campaign: '', BrandDeal: '', Recommendation: '', Session: '', PageView: '', Click: '', ScrollMilestone: '', TimelineEvent: '', AnonVisitor: '', AEOAudit: '', WhatsAppConversation: '', WhatsAppMessage: '', CloudflareHostname: '', CloudflareTunnel: '', Chain: '', ChainLink: '', FileArtifact: '', VoiceProfile: '', VoiceEdit: '', } /** * Inner SVG markup (Lucide glyph) for a label, or null when the label has no * registered icon. A null return is the signal the caller renders the fallback * dot and logs op=icon-fallback. */ export function iconForLabel(label: string): string | null { return Object.prototype.hasOwnProperty.call(LABEL_ICONS, label) ? LABEL_ICONS[label] : null } /** * A self-contained node image for vis-network's shape:'image' — a filled disc * in the label's colour with the Lucide glyph stroked in white on top, encoded * as a data:image/svg+xml URI. Returns null when the label has no icon, so the * caller falls back to the plain coloured dot. * * The disc replicates the legend colour; the glyph group re-declares the * stroke/fill attributes Lucide's own wrapper carries (fill:none, * stroke:currentColor, width 2, round caps) with color:#fff so both stroked * paths and the few fill='currentColor' marks render white. The glyph is scaled * to 0.6 and centred so it sits inside the disc with a margin. */ export function nodeImageDataUri(label: string): string | null { const inner = iconForLabel(label) if (inner === null) return null const colour = colourForLabel(label) const svg = "" + "" + "" + inner + "" return "data:image/svg+xml," + encodeURIComponent(svg) } export interface ResolvedNodeColour { displayLabel: string | null colour: string driftCandidates: number } /** * Single-source colour resolver. driftCandidates counts registered * sublabels in labels[1..]; a node with two registered sublabels has * driftCandidates = 2 and its rendered hue is order-dependent. */ export function resolveNodeColour(labels: readonly string[]): ResolvedNodeColour { const displayLabel = pickDisplayLabel(labels) const colour = displayLabel ? colourForLabel(displayLabel) : FALLBACK_LABEL_COLOUR let driftCandidates = 0 for (let i = 1; i < labels.length; i++) { if (Object.prototype.hasOwnProperty.call(GRAPH_LABEL_COLOURS, labels[i])) { driftCandidates++ } } return { displayLabel, colour, driftCandidates } } // --------------------------------------------------------------------------- // Display strings — short canvas label + untruncated display name. // Verbatim from platform/ui/app/graph/display-helpers.ts at extraction time. // UI helpers that wrap on zoom tier, pack into multi-line canvas labels, // or compose tooltips stay in display-helpers.ts and call into these. // --------------------------------------------------------------------------- /** * Structural type used by display-string helpers. Mirrors the shape of * GraphNode without coupling to it. */ export interface GraphNodeLike { id: string labels: string[] properties: Record } const ISO_MINUTE_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/ const ISO_SECOND_RE = /^\d{4}-\d{2}-\d{2}T(\d{2}:\d{2}:\d{2})/ /** * YYYY-MM-DD HH:mm from an ISO 8601 prefix. Reads the prefix directly so * timezone-stored UTC timestamps render identically across operator * timezones; parsing to Date + reformatting would flip the day. */ export function formatRunTimestamp(iso: string): string | null { const m = iso.match(ISO_MINUTE_RE) if (!m) return null return `${m[1]} ${m[2]}` } /** HH:mm:ss from an ISO 8601 prefix. Same tz-stable contract. */ export function formatMessageTimestamp(iso: string): string | null { const m = iso.match(ISO_SECOND_RE) if (!m) return null return m[1] } // Estate-agent caption sources. :Property and :Listing carry no `name`; their // identity lives in address/displayName with slug as the always-present natural // key. Shared by pickShortLabel (truncated) and pickDisplayName (untruncated). function estateAddress(props: Record): string { const address = typeof props.address === 'string' ? props.address : '' const slug = typeof props.slug === 'string' ? props.slug : '' return address.length > 0 ? address : slug } function estateListingName(props: Record): string { const displayName = typeof props.displayName === 'string' ? props.displayName : '' const slug = typeof props.slug === 'string' ? props.slug : '' return displayName.length > 0 ? displayName : slug } // :Offer carries no single human label; compose status with the offered price. function offerCaption(props: Record): string { const status = typeof props.status === 'string' ? props.status : '' const price = typeof props.price === 'number' ? `£${props.price}` : typeof props.price === 'string' && props.price.length > 0 ? `£${props.price}` : '' return [status, price].filter((s) => s.length > 0).join(' ') } /** * 24-char canvas label for a node. Label precedence per label family * (ToolCall.toolName, WorkflowRun startedAt formatted, WorkflowStep.label, * Person givenName+familyName compose, Agent displayName/slug, Job * client+#jobId compose, Quote ref, InboundInvoice * supplier+#confirmationNumber compose, WhatsAppGroup clientName, * Message role+timestamp, Property address/slug, Listing displayName/slug, * Viewing date, Offer status+price, Visit purpose/status, PurchaseOrder * poNumber, Invoice confirmationNumber, Risk/Finding/Hypothesis statement), * then a generic * fallthrough across curated keys * (name, title, summary, givenName, subject, text) — :ConversationArchive * rows (Task 397) carry a stable `title` written by the writer so they pick * up the generic-fallback `title` key naturally. Finally a * Conversation-specific `Conv ` fallback so unpopulated conversations * don't render as the literal label. */ export function pickShortLabel(node: GraphNodeLike): string { const props = node.properties const primaryLabel = node.labels[0] if (primaryLabel === 'ToolCall') { const v = props.toolName if (typeof v === 'string' && v.length > 0) { return v.length > 24 ? v.slice(0, 24) + '…' : v } } else if (primaryLabel === 'WorkflowRun') { const v = props.startedAt if (typeof v === 'string' && v.length > 0) { const ts = formatRunTimestamp(v) if (ts) return ts } } else if (primaryLabel === 'WorkflowStep') { const v = props.label if (typeof v === 'string' && v.length > 0) { return v.length > 24 ? v.slice(0, 24) + '…' : v } } else if (primaryLabel === 'Person') { const given = typeof props.givenName === 'string' ? props.givenName : '' const family = typeof props.familyName === 'string' ? props.familyName : '' const full = [given, family].filter((s) => s.length > 0).join(' ') if (full.length > 0) { return full.length > 24 ? full.slice(0, 24) + '…' : full } } else if (primaryLabel === 'Agent') { const dn = typeof props.displayName === 'string' ? props.displayName : '' const slug = typeof props.slug === 'string' ? props.slug : '' const v = dn.length > 0 ? dn : slug if (v.length > 0) { return v.length > 24 ? v.slice(0, 24) + '…' : v } } else if (primaryLabel === 'Job') { const client = typeof props.client === 'string' ? props.client : '' const jobId = typeof props.jobId === 'string' ? props.jobId : '' const composed = [client, jobId.length > 0 ? `#${jobId}` : ''] .filter((s) => s.length > 0) .join(' ') if (composed.length > 0) { return composed.length > 24 ? composed.slice(0, 24) + '…' : composed } } else if (primaryLabel === 'Quote') { const ref = typeof props.ref === 'string' ? props.ref : '' if (ref.length > 0) { return ref.length > 24 ? ref.slice(0, 24) + '…' : ref } } else if (primaryLabel === 'InboundInvoice') { const supplier = typeof props.supplier === 'string' ? props.supplier : '' const conf = typeof props.confirmationNumber === 'string' ? props.confirmationNumber : '' const composed = [supplier, conf.length > 0 ? `#${conf}` : ''] .filter((s) => s.length > 0) .join(' ') if (composed.length > 0) { return composed.length > 24 ? composed.slice(0, 24) + '…' : composed } } else if (primaryLabel === 'WhatsAppGroup') { const clientName = typeof props.clientName === 'string' ? props.clientName : '' if (clientName.length > 0) { return clientName.length > 24 ? clientName.slice(0, 24) + '…' : clientName } } else if (primaryLabel === 'CashEntry') { const desc = typeof props.description === 'string' ? props.description : '' if (desc.length > 0) { return desc.length > 24 ? desc.slice(0, 24) + '…' : desc } } else if (primaryLabel === 'Message') { const role = typeof props.role === 'string' ? props.role : '' const roleShort = role === 'user' ? 'user' : role === 'assistant' ? 'asst' : role === 'system' ? 'sys' : role === 'tool' ? 'tool' : null const createdAt = typeof props.createdAt === 'string' ? props.createdAt : '' if (roleShort && createdAt) { const ts = formatMessageTimestamp(createdAt) if (ts) return `${roleShort} ${ts}` } } else if (primaryLabel === 'Property') { const v = estateAddress(props) if (v.length > 0) return v.length > 24 ? v.slice(0, 24) + '…' : v } else if (primaryLabel === 'Listing') { const v = estateListingName(props) if (v.length > 0) return v.length > 24 ? v.slice(0, 24) + '…' : v } else if (primaryLabel === 'Viewing') { const v = typeof props.date === 'string' ? props.date : '' if (v.length > 0) return v.length > 24 ? v.slice(0, 24) + '…' : v } else if (primaryLabel === 'Offer') { const v = offerCaption(props) if (v.length > 0) return v.length > 24 ? v.slice(0, 24) + '…' : v } else if (primaryLabel === 'Visit') { const purpose = typeof props.purpose === 'string' ? props.purpose : '' const status = typeof props.status === 'string' ? props.status : '' const v = purpose.length > 0 ? purpose : status if (v.length > 0) return v.length > 24 ? v.slice(0, 24) + '…' : v } else if (primaryLabel === 'PurchaseOrder') { const v = typeof props.poNumber === 'string' ? props.poNumber : '' if (v.length > 0) return v.length > 24 ? v.slice(0, 24) + '…' : v } else if (primaryLabel === 'Invoice') { const v = typeof props.confirmationNumber === 'string' ? props.confirmationNumber : '' if (v.length > 0) return v.length > 24 ? v.slice(0, 24) + '…' : v } else if (primaryLabel === 'Risk' || primaryLabel === 'Finding' || primaryLabel === 'Hypothesis') { const v = typeof props.statement === 'string' ? props.statement : '' if (v.length > 0) return v.length > 24 ? v.slice(0, 24) + '…' : v } for (const k of ['name', 'title', 'summary', 'givenName', 'subject', 'text']) { const v = props[k] if (typeof v === 'string' && v.length > 0) { return v.length > 24 ? v.slice(0, 24) + '…' : v } } if (primaryLabel === 'Conversation') { const id = typeof props.sessionId === 'string' ? props.sessionId : null if (id) return `Conv ${id.slice(0, 8)}` } return primaryLabel ?? '…' } /** * Untruncated sibling of pickShortLabel. Used by side-panel headings and * tooltips where the 24-char canvas-overlap cap does not apply. */ export function pickDisplayName(node: GraphNodeLike): string { const props = node.properties const primaryLabel = node.labels[0] if (primaryLabel === 'ToolCall') { const v = props.toolName if (typeof v === 'string' && v.length > 0) return v } else if (primaryLabel === 'WorkflowRun') { const v = props.startedAt if (typeof v === 'string' && v.length > 0) { const ts = formatRunTimestamp(v) if (ts) return ts } } else if (primaryLabel === 'WorkflowStep') { const v = props.label if (typeof v === 'string' && v.length > 0) return v } else if (primaryLabel === 'Person') { const given = typeof props.givenName === 'string' ? props.givenName : '' const family = typeof props.familyName === 'string' ? props.familyName : '' const full = [given, family].filter((s) => s.length > 0).join(' ') if (full.length > 0) return full } else if (primaryLabel === 'Agent') { const dn = typeof props.displayName === 'string' ? props.displayName : '' const slug = typeof props.slug === 'string' ? props.slug : '' const v = dn.length > 0 ? dn : slug if (v.length > 0) return v } else if (primaryLabel === 'Job') { const client = typeof props.client === 'string' ? props.client : '' const jobId = typeof props.jobId === 'string' ? props.jobId : '' const composed = [client, jobId.length > 0 ? `#${jobId}` : ''] .filter((s) => s.length > 0) .join(' ') if (composed.length > 0) return composed } else if (primaryLabel === 'Quote') { const ref = typeof props.ref === 'string' ? props.ref : '' if (ref.length > 0) return ref } else if (primaryLabel === 'InboundInvoice') { const supplier = typeof props.supplier === 'string' ? props.supplier : '' const conf = typeof props.confirmationNumber === 'string' ? props.confirmationNumber : '' const composed = [supplier, conf.length > 0 ? `#${conf}` : ''] .filter((s) => s.length > 0) .join(' ') if (composed.length > 0) return composed } else if (primaryLabel === 'WhatsAppGroup') { const clientName = typeof props.clientName === 'string' ? props.clientName : '' if (clientName.length > 0) return clientName } else if (primaryLabel === 'CashEntry') { const desc = typeof props.description === 'string' ? props.description : '' if (desc.length > 0) return desc } else if (primaryLabel === 'Property') { const v = estateAddress(props) if (v.length > 0) return v } else if (primaryLabel === 'Listing') { const v = estateListingName(props) if (v.length > 0) return v } else if (primaryLabel === 'Viewing') { const v = typeof props.date === 'string' ? props.date : '' if (v.length > 0) return v } else if (primaryLabel === 'Offer') { const v = offerCaption(props) if (v.length > 0) return v } else if (primaryLabel === 'Visit') { const purpose = typeof props.purpose === 'string' ? props.purpose : '' const status = typeof props.status === 'string' ? props.status : '' const v = purpose.length > 0 ? purpose : status if (v.length > 0) return v } else if (primaryLabel === 'PurchaseOrder') { const v = typeof props.poNumber === 'string' ? props.poNumber : '' if (v.length > 0) return v } else if (primaryLabel === 'Invoice') { const v = typeof props.confirmationNumber === 'string' ? props.confirmationNumber : '' if (v.length > 0) return v } else if (primaryLabel === 'Risk' || primaryLabel === 'Finding' || primaryLabel === 'Hypothesis') { const v = typeof props.statement === 'string' ? props.statement : '' if (v.length > 0) return v } for (const k of ['name', 'title', 'summary', 'givenName', 'subject', 'text']) { const v = props[k] if (typeof v === 'string' && v.length > 0) return v } if (primaryLabel === 'Conversation') { const id = typeof props.sessionId === 'string' ? props.sessionId : null if (id) return `Conv ${id.slice(0, 8)}` } return primaryLabel ?? '…' }