Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
531 changes: 530 additions & 1 deletion hub/blueprint/bp-ai-assistant-platform.radical

Large diffs are not rendered by default.

474 changes: 474 additions & 0 deletions hub/blueprint/bp-analytical-data-platform.radical

Large diffs are not rendered by default.

637 changes: 637 additions & 0 deletions hub/blueprint/bp-ecommerce-platform.radical

Large diffs are not rendered by default.

542 changes: 541 additions & 1 deletion hub/blueprint/bp-fintech-ledger-platform.radical

Large diffs are not rendered by default.

564 changes: 563 additions & 1 deletion hub/blueprint/bp-marketplace-platform.radical

Large diffs are not rendered by default.

437 changes: 436 additions & 1 deletion hub/blueprint/bp-realtime-collaboration.radical

Large diffs are not rendered by default.

586 changes: 586 additions & 0 deletions hub/blueprint/bp-saas-b2b-platform.radical

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions src/renderer/src/components/HubImportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { C4Node, C4Relation, C4ElementType, DiagramSequence } from '../type
import { NODE_SIZES } from '../types/c4'
import { isParentAllowed } from '../types/metamodel'
import { nodeTypeTheme } from '../types/hubTheme'
import { conceptViewsToDiagram } from '../hub/conceptViews'

// ─── Props ──────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -570,11 +571,13 @@ export function HubImportModal({ open, onClose, preselectedIds }: Props): React.

// Sequences (dynamic flows) survive only if every step's relation was imported.
const newSequences: Record<string, DiagramSequence> = {}
const seqIdMap = new Map<string, string>()
for (const raw of concept.sequences ?? []) {
const ids = (raw.relationIds as string[] | undefined) ?? []
const mapped = ids.map((id) => relIdMap.get(id))
if (ids.length === 0 || mapped.some((id) => !id)) continue
const seqId = crypto.randomUUID()
if (typeof raw.id === 'string') seqIdMap.set(raw.id, seqId)
newSequences[seqId] = {
id: seqId,
name: (raw.name as string) || concept.name,
Expand All @@ -585,12 +588,20 @@ export function HubImportModal({ open, onClose, preselectedIds }: Props): React.

// Single undo + bulk insert — bypasses per-node metamodel validation
// so curated hub concepts always import cleanly.
// Named views travel with the concept, restricted to what was imported.
const newViews = conceptViewsToDiagram(
concept.views,
{ node: (id) => idMap.get(id), relation: (id) => relIdMap.get(id), sequence: (id) => seqIdMap.get(id) },
{ newId: () => crypto.randomUUID(), namePrefix: concept.name },
)

store._pushUndo()
store._markMilestoneEdit()
useDiagramStore.setState((state) => {
Object.assign(state.c4Nodes, newNodes)
Object.assign(state.c4Relations, newRelations)
Object.assign(state.sequences, newSequences)
for (const v of newViews) state.views[v.id] = v
if (state.activeViewId && state.views[state.activeViewId]) {
state.views[state.activeViewId].nodeIds.push(...Object.keys(newNodes))
}
Expand Down
53 changes: 46 additions & 7 deletions src/renderer/src/hub/HubApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,10 @@ function HubAppInner(): React.ReactElement {
const [theme, setTheme] = useState<'dark' | 'light'>(() => (localStorage.getItem(LS_THEME) as 'dark' | 'light') || 'dark')
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set())
const [tagsExpanded, setTagsExpanded] = useState(false)
// Pending view kind from a deep link, applied once the concept is loaded.
// Pending view kind (and named canvas view) from a deep link, applied once the concept is loaded.
const pendingView = useRef<HubViewKind | undefined>(parseHubHash(window.location.hash).view)
const pendingCanvasView = useRef<string | undefined>(parseHubHash(window.location.hash).canvasView)
const views = useDiagramStore((s) => s.views)

useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
Expand All @@ -291,6 +293,13 @@ function HubAppInner(): React.ReactElement {
)
const [conceptError, setConceptError] = useState<string | null>(null)
const viewKind = kindForViewId(activeViewId)
// The concept's own named canvas views ("Containers", a screen flow, …);
// `null` = the full model ("All elements").
const canvasViews = useMemo(
() => Object.values(views).filter((v) => kindForViewId(v.id) === 'canvas'),
[views],
)
const canvasViewId = viewKind === 'canvas' ? activeViewId : null

// Fetch the selected concept's .radical file once the index knows about it.
useEffect(() => {
Expand All @@ -308,8 +317,12 @@ function HubAppInner(): React.ReactElement {
if (!concept) return
loadDiagram(conceptToDiagramData(concept))
const kind = pendingView.current ?? defaultViewKind(concept)
const named = pendingCanvasView.current
pendingView.current = undefined
setActiveView(viewIdForKind(kind))
pendingCanvasView.current = undefined
const hasNamed = kind === 'canvas' && !!named && (concept.views ?? []).some((v) => v.id === named)
if (hasNamed) loadDiagram(conceptToDiagramData(concept, { layoutFrom: named }))
setActiveView(hasNamed ? named! : viewIdForKind(kind))
if (kind === 'canvas') return scheduleFit(fitAll)
}, [concept, loadDiagram, setActiveView, fitAll])

Expand All @@ -328,6 +341,7 @@ function HubAppInner(): React.ReactElement {
browse: browsing,
concept: concept?.id,
view: concept ? viewKind : undefined,
canvasView: concept && canvasViewId ? canvasViewId : undefined,
category: activeCategory ?? undefined,
tag: setToCsv(activeTags),
status: setToCsv(activeStatuses),
Expand All @@ -339,14 +353,15 @@ function HubAppInner(): React.ReactElement {
const url = hash || window.location.pathname
if (changed) history.pushState(null, '', url)
else history.replaceState(null, '', url)
}, [browsing, concept, viewKind, activeCategory, activeTags, activeStatuses, sortBy])
}, [browsing, concept, viewKind, canvasViewId, activeCategory, activeTags, activeStatuses, sortBy])

// URL → state (back/forward, pasted links).
useEffect(() => {
const onHash = () => {
const r = parseHubHash(window.location.hash)
setBrowsing(!!r.browse)
if (r.concept !== conceptId) { pendingView.current = r.view; setConceptId(r.concept) }
if (r.concept !== conceptId) { pendingView.current = r.view; pendingCanvasView.current = r.canvasView; setConceptId(r.concept) }
else if (r.view === 'canvas' && (r.canvasView ?? null) !== canvasViewId) switchCanvasViewRef.current(r.canvasView ?? null)
else if (r.view && r.view !== viewKind) setActiveView(viewIdForKind(r.view))
setCategory(r.category ?? null)
setTags(csvToSet(r.tag))
Expand All @@ -359,12 +374,23 @@ function HubAppInner(): React.ReactElement {
window.removeEventListener('hashchange', onHash)
window.removeEventListener('popstate', onHash)
}
}, [conceptId, viewKind, setActiveView, setCategory, setTags, setStatuses, setSortBy])
}, [conceptId, viewKind, canvasViewId, setActiveView, setCategory, setTags, setStatuses, setSortBy])

// Reload the concept laid out for the chosen view (null = the full model
// in its default layout) — see conceptToDiagramData's `layoutFrom`.
const switchCanvasView = useCallback((viewId: string | null) => {
if (!concept) return
loadDiagram(conceptToDiagramData(concept, { layoutFrom: viewId }))
setActiveView(viewId)
scheduleFit(fitAll)
}, [concept, loadDiagram, setActiveView, fitAll])
const switchCanvasViewRef = useRef(switchCanvasView)
switchCanvasViewRef.current = switchCanvasView

const switchView = useCallback((kind: HubViewKind) => {
if (kind === 'canvas') return switchCanvasView(null)
setActiveView(viewIdForKind(kind))
if (kind === 'canvas') scheduleFit(fitAll)
}, [setActiveView, fitAll])
}, [setActiveView, switchCanvasView])

const toggleSelect = useCallback((id: string) => {
setSelectedIds((prev) => toggleInSet(prev, id))
Expand Down Expand Up @@ -663,6 +689,19 @@ function HubAppInner(): React.ReactElement {
</div>
{viewKind === 'canvas' && (
<>
{canvasViews.length > 0 && (
<select
className="hub-view-select"
value={canvasViewId ?? ''}
onChange={(e) => switchCanvasView(e.target.value || null)}
title="Named views of this concept"
>
<option value="">All elements</option>
{canvasViews.map((v) => (
<option key={v.id} value={v.id}>{v.name}</option>
))}
</select>
)}
<button type="button" className="toolbar-btn" onClick={fitAll} title="Fit all nodes to viewport"><IconFitAll /> Fit</button>
<SmartLayoutButton readOnlyNote="Exploration only — nothing is saved." />
</>
Expand Down
22 changes: 21 additions & 1 deletion src/renderer/src/hub/conceptToDiagram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { C4Node, C4Relation, C4ElementType, DiagramData, DiagramSequence, D
import { NODE_SIZES } from '../types/c4'
import { builtInGovernanceMetamodel } from '../types/metamodel'
import type { HubConcept, TemplateParam } from '../store/hubStore'
import { conceptViewsToDiagram } from './conceptViews'

export const HUB_WIKI_VIEW_ID = 'hub-wiki'
export const HUB_TABLE_VIEW_ID = 'hub-table'
Expand Down Expand Up @@ -52,7 +53,11 @@ export function substituteTemplateDefaults(str: string, params: TemplateParam[]
})
}

export function conceptToDiagramData(concept: HubConcept): DiagramData {
/** `layoutFrom`: id of one of the concept's views whose stored positions
* replace the node coordinates — the Hub viewer runs in explore mode, where
* switching the active view deliberately keeps the current layout, so it
* reloads the concept laid out for the chosen view instead. */
export function conceptToDiagramData(concept: HubConcept, opts: { layoutFrom?: string | null } = {}): DiagramData {
const ids = new Set(concept.nodes.map((n) => String(n.id)))
const params = concept.templateParams

Expand Down Expand Up @@ -102,11 +107,26 @@ export function conceptToDiagramData(concept: HubConcept): DiagramData {

const allIds = nodes.map((n) => n.id)
const firstRoot = nodes.find((n) => !n.parentId)?.id ?? allIds[0] ?? null
const seqIds = new Set(sequences.map((s) => s.id))
const keep = (set: Set<string>) => (id: string) => (set.has(id) ? id : undefined)
const views: DiagramView[] = [
...conceptViewsToDiagram(
concept.views,
{ node: keep(ids), relation: keep(relIds), sequence: keep(seqIds) },
{ keepPositions: true },
),
{ id: HUB_WIKI_VIEW_ID, name: 'Wiki', kind: 'wiki', nodeIds: allIds, positions: {}, wikiFocusId: firstRoot },
{ id: HUB_TABLE_VIEW_ID, name: 'Table', kind: 'table', nodeIds: allIds, positions: {} },
]

const layout = opts.layoutFrom ? views.find((v) => v.id === opts.layoutFrom)?.positions : undefined
if (layout) {
for (const n of nodes) {
const p = layout[n.id]
if (p) Object.assign(n, { x: p.x, y: p.y, width: p.width, height: p.height })
}
}

return {
nodes,
relations,
Expand Down
97 changes: 97 additions & 0 deletions src/renderer/src/hub/conceptViews.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// ─── Hub concept views → studio views ───────────────────────────────────────
// A concept file may carry named canvas views (`views`: static diagrams such
// as "Containers" or a screen flow, and dynamic views that play a sequence).
// The same translation is needed in three places — the Hub viewer (ids kept
// as-is) and both studio import paths (ids remapped to the freshly created
// nodes / relations / sequences) — so it lives here once.
//
// Only canvas kinds are taken over: the Hub builds its own wiki / table views
// and the studio has its defaults. A view keeps only the elements that exist
// on the receiving side, so a partial blueprint import still gets sensible
// views, and a view left with no nodes is dropped. A dynamic view whose
// sequence did not survive falls back to a static view of the same nodes.
//
// Per-view positions are only meaningful where node ids and coordinates are
// the concept's own (the Hub viewer); an import re-ids nodes and re-centres
// them on the viewport, so it drops them and the view keeps the imported
// layout.

import type { DiagramView, NodePosition } from '../types/c4'

type Raw = Record<string, unknown>
type IdMap = (id: string) => string | undefined

export interface ConceptViewMaps {
node: IdMap
relation: IdMap
sequence: IdMap
}

export interface ConceptViewOptions {
/** New id for each view (import); omitted → keep the concept's view id. */
newId?: () => string
/** Prepended to each view name, e.g. the concept name on import. */
namePrefix?: string
/** Keep the view's stored node positions (only when ids are unchanged). */
keepPositions?: boolean
}

function mapPositions(raw: unknown, map: IdMap): Record<string, NodePosition> {
const out: Record<string, NodePosition> = {}
if (!raw || typeof raw !== 'object') return out
for (const [id, p] of Object.entries(raw as Record<string, unknown>)) {
const mapped = map(id)
const pos = p as Record<string, unknown> | null
if (!mapped || !pos) continue
const { x, y, width, height } = pos
// applyPositions overwrites width/height too, so an entry without them
// would blank the node's size — skip anything incomplete.
if ([x, y, width, height].every((v) => typeof v === 'number')) {
out[mapped] = { x, y, width, height } as NodePosition
}
}
return out
}

function mapIds(raw: unknown, map: IdMap): string[] {
if (!Array.isArray(raw)) return []
const out: string[] = []
for (const id of raw) {
if (typeof id !== 'string') continue
const mapped = map(id)
if (mapped) out.push(mapped)
}
return out
}

export function conceptViewsToDiagram(
rawViews: Raw[] | undefined,
maps: ConceptViewMaps,
opts: ConceptViewOptions = {},
): DiagramView[] {
const out: DiagramView[] = []
for (const [i, raw] of (rawViews ?? []).entries()) {
const kind = raw.kind === 'dynamic' ? 'dynamic' : raw.kind === undefined || raw.kind === 'static' ? 'static' : null
if (!kind) continue
const nodeIds = mapIds(raw.nodeIds, maps.node)
if (nodeIds.length === 0) continue
const sequenceId = kind === 'dynamic' && typeof raw.sequenceId === 'string' ? maps.sequence(raw.sequenceId) : undefined
const name = typeof raw.name === 'string' && raw.name ? raw.name : `View ${i + 1}`
const view: DiagramView = {
id: opts.newId ? opts.newId() : typeof raw.id === 'string' ? raw.id : `view-${i}`,
name: opts.namePrefix ? `${opts.namePrefix} — ${name}` : name,
kind: sequenceId ? 'dynamic' : 'static',
nodeIds,
positions: opts.keepPositions ? mapPositions(raw.positions, maps.node) : {},
}
if (sequenceId) view.sequenceId = sequenceId
const collapsed = mapIds(raw.collapsedNodeIds, maps.node)
if (collapsed.length) view.collapsedNodeIds = collapsed
const expanded = mapIds(raw.expandedNodeIds, maps.node)
if (expanded.length) view.expandedNodeIds = expanded
const hidden = mapIds(raw.hiddenRelationIds, maps.relation)
if (hidden.length) view.hiddenRelationIds = hidden
out.push(view)
}
return out
}
11 changes: 11 additions & 0 deletions src/renderer/src/hub/hub.css
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,17 @@
}
.hub-list-count { display: flex; align-items: center; gap: 8px; }
.hub-sort { display: flex; align-items: center; gap: 6px; }
.hub-view-select {
max-width: 260px;
height: 28px;
padding: 0 8px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--input-bg);
color: var(--text-primary);
font: inherit;
font-size: 12px;
}
.hub-sort select {
height: 24px; padding: 0 5px;
border-radius: 6px;
Expand Down
8 changes: 7 additions & 1 deletion src/renderer/src/hub/hubFormat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export interface HubRadicalDoc {
nodes: Array<Record<string, unknown>>
relations?: Array<Record<string, unknown>>
sequences?: Array<Record<string, unknown>>
/** Named canvas views (static, or dynamic over a sequence). */
views?: Array<Record<string, unknown>>
[extra: string]: unknown
}

Expand All @@ -56,6 +58,8 @@ export interface HubConcept extends HubConceptMeta {
relations?: Array<Record<string, unknown>>
/** Named flows over `relations` (Studio dynamic views). */
sequences?: Array<Record<string, unknown>>
/** Named canvas views — see hub/conceptViews.ts. */
views?: Array<Record<string, unknown>>
}

/** One entry of `hub/index.json`. Enough to render cards and decide drop
Expand All @@ -82,14 +86,16 @@ export function conceptFile(meta: Pick<HubConceptMeta, 'id' | 'category'>): stri
export function docToConcept(doc: HubRadicalDoc): HubConcept {
const c: HubConcept = { ...doc.hub, nodes: doc.nodes, relations: doc.relations }
if (doc.sequences?.length) c.sequences = doc.sequences
if (doc.views?.length) c.views = doc.views
return c
}

export function conceptToDoc(concept: HubConcept): HubRadicalDoc {
const { nodes, relations, sequences, ...hub } = concept
const { nodes, relations, sequences, views, ...hub } = concept
const d: HubRadicalDoc = { hub, nodes }
if (relations) d.relations = relations
if (sequences?.length) d.sequences = sequences
if (views?.length) d.views = views
return d
}

Expand Down
7 changes: 6 additions & 1 deletion src/renderer/src/hub/hubRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// the catalogue:
//
// #/browse
// #/c/<conceptId>[/v/<canvas|wiki|table>][/cat/<category>][/tag/<tag,tag>][/status/<status,status>][/sort/<name|category|connections>]
// #/c/<conceptId>[/v/<canvas|wiki|table>][/cv/<canvasViewId>][/cat/<category>][/tag/<tag,tag>][/status/<status,status>][/sort/<name|category|connections>]
//
// `c` selects the concept shown in the viewer, `v` its presentation, `cat` /
// `tag` / `status` / `sort` the catalogue filters. `tag` and `status` hold a
Expand All @@ -21,6 +21,8 @@ export interface HubRoute {
browse?: boolean
concept?: string
view?: HubViewKind
/** Named canvas view of the concept (only with view = canvas). */
canvasView?: string
category?: string
/** Comma-separated tag list. */
tag?: string
Expand Down Expand Up @@ -49,6 +51,7 @@ export function parseHubHash(hash: string): HubRoute {
const view = (VIEW_KINDS as readonly string[]).includes(map.v) ? (map.v as HubViewKind) : undefined
const sort = (SORT_KEYS as readonly string[]).includes(map.sort) ? (map.sort as HubSortKey) : undefined
const concept = map.c || undefined
const canvasView = view === 'canvas' || !view ? map.cv || undefined : undefined
const category = map.cat || undefined
const tag = map.tag || undefined
const status = map.status || undefined
Expand All @@ -57,6 +60,7 @@ export function parseHubHash(hash: string): HubRoute {
...(browse ? { browse: true } : {}),
concept,
view,
...(canvasView ? { canvasView } : {}),
category,
tag,
status,
Expand All @@ -68,6 +72,7 @@ export function formatHubHash(route: HubRoute): string {
const segs: string[] = []
if (route.concept) segs.push('c', encodeURIComponent(route.concept))
if (route.concept && route.view) segs.push('v', route.view)
if (route.concept && route.view === 'canvas' && route.canvasView) segs.push('cv', encodeURIComponent(route.canvasView))
if (route.category) segs.push('cat', encodeURIComponent(route.category))
if (route.tag) segs.push('tag', encodeURIComponent(route.tag))
if (route.status) segs.push('status', encodeURIComponent(route.status))
Expand Down
Loading
Loading