import { ConceptRepository } from "./concept-repository.js"; const PLACEMENT_FIELDS = [ "parentCmapLink", "groupId", "childMap", "parentSubmapId", "submapDepth", "expanded", "submapInitialized", "separateMap", "mapReference", "hiddenContexts", "layouts", "backgroundColor", "borderColor", "submapBackgroundColor", "submapBorderColor", "textColor", "fontFamily", "fontSize", "fontWeight", "fontStyle", "synopsisTextColor", "synopsisFontFamily", "synopsisFontSize", "synopsisFontWeight", "synopsisFontStyle", "width", "height", "autoWidth", "autoHeight", "x", "y" ]; function copy(value) { return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); } /** Join one repository concept to its placement and appearance on a CMap. */ export class ConceptMapConcept { constructor(id, conceptId, values = {}) { if (!Number.isInteger(Number(id)) || !conceptId) { throw new TypeError("A concept placement requires an integer id and concept id"); } this.id = Number(id); this.conceptId = String(conceptId); this.kind = values.kind || "concept"; this.update(values); } update(values = {}) { for (const field of PLACEMENT_FIELDS) { if (values[field] !== undefined) this[field] = copy(values[field]); } this.parentCmapLink = Boolean(this.parentCmapLink); this.parentSubmapId = this.parentSubmapId === null || this.parentSubmapId === undefined ? null : Number(this.parentSubmapId); this.submapDepth = Number(this.submapDepth) || 0; this.expanded = Boolean(this.expanded); this.submapInitialized = Boolean(this.submapInitialized); this.separateMap = Boolean(this.separateMap); this.hiddenContexts = this.hiddenContexts instanceof Set ? this.hiddenContexts : new Set(Array.isArray(this.hiddenContexts) ? this.hiddenContexts.map(String) : []); this.layouts = this.layouts && typeof this.layouts === "object" ? this.layouts : {}; this.autoWidth = Boolean(this.autoWidth); this.autoHeight = Boolean(this.autoHeight); return this; } toDocument() { const document = { id: this.id, conceptId: this.conceptId, kind: this.kind }; for (const field of PLACEMENT_FIELDS) { document[field] = field === "hiddenContexts" ? [...this.hiddenContexts] : copy(this[field]); } return document; } } /** Represent a map-local linking phrase without creating a repository concept. */ export class ConceptMapPhrase { constructor(id, values = {}) { if (!Number.isInteger(Number(id))) throw new TypeError("A phrase requires an integer id"); this.id = Number(id); this.kind = "phrase"; this.label = String(values.label || "?????"); this.synopsis = String(values.synopsis || ""); this.update(values); } update(values = {}) { if (values.label !== undefined) this.label = String(values.label); if (values.synopsis !== undefined) this.synopsis = String(values.synopsis); for (const field of PLACEMENT_FIELDS) { if (values[field] !== undefined) this[field] = copy(values[field]); } this.parentSubmapId = this.parentSubmapId === null || this.parentSubmapId === undefined ? null : Number(this.parentSubmapId); this.hiddenContexts = this.hiddenContexts instanceof Set ? this.hiddenContexts : new Set(Array.isArray(this.hiddenContexts) ? this.hiddenContexts.map(String) : []); this.layouts = this.layouts && typeof this.layouts === "object" ? this.layouts : {}; return this; } toDocument() { const document = { id: this.id, conceptId: null, kind: "phrase", label: this.label, synopsis: this.synopsis }; for (const field of PLACEMENT_FIELDS) { document[field] = field === "hiddenContexts" ? [...this.hiddenContexts] : copy(this[field]); } return document; } } /** Store one rendered connection between two map-local item placements. */ export class ConceptMapConnector { constructor(id, sourceId, targetId, values = {}) { if (!Number.isInteger(Number(id)) || !Number.isInteger(Number(sourceId)) || !Number.isInteger(Number(targetId))) { throw new TypeError("A connector requires integer ids"); } this.id = Number(id); this.sourceId = Number(sourceId); this.targetId = Number(targetId); this.relationId = values.relationId || null; this.hasArrow = values.hasArrow !== false; this.lineColor = values.lineColor || "#333"; this.lineWidth = Number(values.lineWidth) || 2; } toDocument() { return { id: this.id, sourceId: this.sourceId, targetId: this.targetId, ...(this.relationId ? { relationId: this.relationId } : {}), hasArrow: this.hasArrow, lineColor: this.lineColor, lineWidth: this.lineWidth }; } } /** Own the placements, phrases, connectors and metadata of one CMap. */ export class ConceptMap { constructor(values = {}) { this.schemaVersion = Number(values.schemaVersion) || 2; this.metadata = { namespace: String(values.metadata?.namespace || "").trim(), tags: Array.isArray(values.metadata?.tags) ? values.metadata.tags.map(String) : [], summary: String(values.metadata?.summary || ""), explanationPageSlug: String(values.metadata?.explanationPageSlug || "") }; this.derivedView = values.derivedView ? copy(values.derivedView) : null; this.itemsById = new Map(); this.connectorsById = new Map(); this.conceptMapsById = new Map(); } addItem(value) { const item = value instanceof ConceptMapConcept || value instanceof ConceptMapPhrase ? value : (value.kind === "phrase" ? new ConceptMapPhrase(value.id, value) : new ConceptMapConcept(value.id, value.conceptId, value)); if (this.itemsById.has(item.id)) throw new Error(`Duplicate CMap item id: ${item.id}`); this.itemsById.set(item.id, item); return item; } item(id) { return this.itemsById.get(Number(id)) || null; } items() { return [...this.itemsById.values()]; } removeItem(id) { const itemId = Number(id); this.itemsById.delete(itemId); for (const [connectorId, connector] of this.connectorsById) { if (connector.sourceId === itemId || connector.targetId === itemId) { this.connectorsById.delete(connectorId); } } } addConnector(value) { const connector = value instanceof ConceptMapConnector ? value : new ConceptMapConnector( value.id, value.sourceId, value.targetId, value); if (this.connectorsById.has(connector.id)) { throw new Error(`Duplicate CMap connector id: ${connector.id}`); } this.connectorsById.set(connector.id, connector); return connector; } connector(id) { return this.connectorsById.get(Number(id)) || null; } connectors() { return [...this.connectorsById.values()]; } removeConnector(id) { this.connectorsById.delete(Number(id)); } setConceptMapReferences(references = []) { this.conceptMapsById = new Map(references .filter((reference) => reference && reference.id) .map((reference) => [reference.id, copy(reference)])); } toDocument() { const document = { schemaVersion: this.schemaVersion, metadata: copy(this.metadata), items: this.items().map((item) => item.toDocument()), connectors: this.connectors().map((connector) => connector.toDocument()), conceptMaps: [...this.conceptMapsById.values()].map(copy) }; if (this.derivedView) document.derivedView = copy(this.derivedView); return document; } } /** Combine a concept repository with one concrete concept-map document. */ export class CmapModel { constructor(repository = new ConceptRepository(), conceptMap = new ConceptMap()) { this.repository = repository; this.conceptMap = conceptMap; } static fromDocument(document = {}) { const repository = new ConceptRepository( Array.isArray(document.concepts) ? document.concepts : [], Array.isArray(document.conceptRelations) ? document.conceptRelations : [], Array.isArray(document.conceptOwnerships) ? document.conceptOwnerships : []); const conceptMap = new ConceptMap(document); for (const item of (Array.isArray(document.items) ? document.items : [])) { if (item.kind !== "phrase" && !repository.concept(item.conceptId)) { repository.add({ id: item.conceptId, label: item.label || "Concept" }); } conceptMap.addItem(item); } for (const connector of (Array.isArray(document.connectors) ? document.connectors : [])) { conceptMap.addConnector(connector); } conceptMap.setConceptMapReferences(document.conceptMaps); return new CmapModel(repository, conceptMap); } /** Return a detached copy of the map metadata. */ metadata() { return copy(this.conceptMap.metadata); } /** Return a detached derived-view reference, or null for an independent map. */ derivedView() { return copy(this.conceptMap.derivedView); } /** Return connectors that cross the active submap boundary. */ boundaryReferencesFor(activeRootId) { if (activeRootId === null || activeRootId === undefined) return []; const rootId = Number(activeRootId); const itemInside = (item) => { if (!item) return false; if (item.id === rootId) return true; let parentId = item.parentSubmapId; while (parentId !== null && parentId !== undefined) { if (Number(parentId) === rootId) return true; parentId = this.conceptMap.item(parentId)?.parentSubmapId; } return false; }; const conceptFor = (item) => item.kind === "phrase" ? null : this.repository.concept(item.conceptId); const placement = (item) => ({ id: item.id, x: Number(item.x) || 0, y: Number(item.y) || 0, width: Number(item.width) || 0, height: Number(item.height) || 0 }); const itemForSide = (item, excludedItem, inside) => { if (item.kind !== "phrase") return item; const adjacent = this.conceptMap.connectors() .filter((candidate) => candidate.sourceId === item.id || candidate.targetId === item.id) .map((candidate) => this.conceptMap.item( candidate.sourceId === item.id ? candidate.targetId : candidate.sourceId)) .find((candidate) => candidate && candidate !== excludedItem && candidate.kind !== "phrase" && itemInside(candidate) === inside); return adjacent || item; }; const result = []; for (const connector of this.conceptMap.connectors()) { const source = this.conceptMap.item(connector.sourceId); const target = this.conceptMap.item(connector.targetId); if (!source || !target) continue; const sourceInside = itemInside(source); const targetInside = itemInside(target); if (sourceInside === targetInside) continue; const rawInside = sourceInside ? source : target; const rawOutside = sourceInside ? target : source; const inside = itemForSide(rawInside, rawOutside, true); const outside = itemForSide(rawOutside, rawInside, false); if (!itemInside(inside)) continue; result.push({ connector: connector.toDocument(), inside: placement(inside), outside: placement(outside), sourceInside, conceptId: conceptFor(outside)?.id || null, label: conceptFor(outside)?.label || outside.label || "External concept", linkingPhraseLabel: rawInside.kind === "phrase" ? rawInside.label : (rawOutside.kind === "phrase" ? rawOutside.label : null), targetMaps: [] }); } return result; } /** Return a new model with changed map metadata and unchanged contents. */ withMetadata(metadata) { const document = this.toDocument(); document.metadata = copy(metadata); return CmapModel.fromDocument(document); } /** * Split an embedded submap into standalone child and remaining parent models. * The owner stays in the parent as an ordinary concept and links to targetSlug. * Descendant placements become the complete contents of the child map. */ extractSubmap(rootItemId, targetSlug = null, childMetadata = null) { const sourceDocument = this.toDocument(); const rootId = Number(rootItemId); const rootItem = sourceDocument.items.find((item) => Number(item.id) === rootId); if (!rootItem || rootItem.kind !== "submap") { throw new TypeError("A submap item is required for extraction"); } const descendantIds = new Set(); let foundDescendant = true; while (foundDescendant) { foundDescendant = false; for (const item of sourceDocument.items) { const parentId = Number(item.parentSubmapId); if (parentId !== rootId && !descendantIds.has(parentId)) continue; if (descendantIds.has(Number(item.id))) continue; descendantIds.add(Number(item.id)); foundDescendant = true; } } const descendantItems = sourceDocument.items .filter((item) => descendantIds.has(Number(item.id))); if (!descendantItems.some((item) => item.kind !== "phrase")) { throw new Error("The submap has no concepts to extract"); } const left = Math.min(...descendantItems.map((item) => Number(item.x) || 0)); const top = Math.min(...descendantItems.map((item) => Number(item.y) || 0)); const childItems = descendantItems.map((item) => { const x = (Number(item.x) || 0) - left + 80; const y = (Number(item.y) || 0) - top + 80; const parentSubmapId = Number(item.parentSubmapId) === rootId ? null : item.parentSubmapId; const submapDepth = Math.max( 0, Number(item.submapDepth || 0) - Number(rootItem.submapDepth || 0) - 1); return { ...item, parentSubmapId, submapDepth, x, y, layouts: { ...(item.layouts || {}), root: { ...(item.layouts?.root || {}), x, y, width: Number(item.width), height: Number(item.height) } } }; }); const parentItems = sourceDocument.items .filter((item) => !descendantIds.has(Number(item.id))) .map((item) => Number(item.id) === rootId ? { ...item, kind: "concept", childMap: null, expanded: false, submapInitialized: false, separateMap: false, mapReference: null } : item); const allItemIds = new Set(sourceDocument.items.map((item) => Number(item.id))); const parentConnectors = []; const parentConnectorKeys = new Set(); const childConnectors = []; for (const connector of sourceDocument.connectors) { const sourceId = Number(connector.sourceId); const targetId = Number(connector.targetId); if (!allItemIds.has(sourceId) || !allItemIds.has(targetId)) continue; const sourceInside = descendantIds.has(sourceId); const targetInside = descendantIds.has(targetId); if (sourceInside && targetInside) { childConnectors.push({ ...connector }); continue; } if (sourceInside || targetInside) { const rewired = { ...connector, sourceId: sourceInside ? rootId : sourceId, targetId: targetInside ? rootId : targetId }; if (rewired.sourceId === rewired.targetId) continue; const key = `${rewired.sourceId}\u0000${rewired.targetId}\u0000${rewired.hasArrow !== false}`; if (!parentConnectorKeys.has(key)) { parentConnectorKeys.add(key); parentConnectors.push(rewired); } continue; } const key = `${sourceId}\u0000${targetId}\u0000${connector.hasArrow !== false}`; if (!parentConnectorKeys.has(key)) { parentConnectorKeys.add(key); parentConnectors.push({ ...connector }); } } const conceptIdsFor = (items) => new Set(items .filter((item) => item.kind !== "phrase" && item.conceptId) .map((item) => String(item.conceptId))); const childConceptIds = conceptIdsFor(childItems); const parentConceptIds = conceptIdsFor(parentItems); const ownerConceptId = String(rootItem.conceptId); const conceptsFor = (ids) => sourceDocument.concepts .filter((concept) => ids.has(String(concept.id))) .map((concept) => ({ ...concept })); const parentConcepts = conceptsFor(parentConceptIds); if (targetSlug) { const ownerConcept = parentConcepts.find((concept) => concept.id === ownerConceptId); if (ownerConcept) ownerConcept.cmapSlug = String(targetSlug); } const relationsFor = (ids) => (sourceDocument.conceptRelations || []) .filter((relation) => ids.has(String(relation.sourceConceptId)) && ids.has(String(relation.targetConceptId))) .map((relation) => ({ ...relation })); const ownershipsFor = (ids) => (sourceDocument.conceptOwnerships || []) .filter((ownership) => { const parentId = String(ownership.parentConceptId); const childId = String(ownership.childConceptId); const extractedOwnership = parentId === ownerConceptId && childConceptIds.has(childId); return !extractedOwnership && ids.has(parentId) && ids.has(childId); }) .map((ownership) => ({ ...ownership })); const ownerConcept = sourceDocument.concepts .find((concept) => String(concept.id) === ownerConceptId) || {}; const extractedMetadata = childMetadata ? copy(childMetadata) : { tags: Array.isArray(ownerConcept.aspects) ? [...ownerConcept.aspects] : [], summary: String(ownerConcept.synopsis || ""), explanationPageSlug: String(ownerConcept.descriptionPageSlug || "") }; const childDocument = { schemaVersion: sourceDocument.schemaVersion, metadata: extractedMetadata, concepts: conceptsFor(childConceptIds), items: childItems, connectors: childConnectors, conceptMaps: sourceDocument.conceptMaps .filter((reference) => descendantIds.has(Number(reference.rootItemId))) }; const childRelations = relationsFor(childConceptIds); const childOwnerships = ownershipsFor(childConceptIds); if (childRelations.length) childDocument.conceptRelations = childRelations; if (childOwnerships.length) childDocument.conceptOwnerships = childOwnerships; const parentDocument = { ...sourceDocument, concepts: parentConcepts, items: parentItems, connectors: parentConnectors, conceptMaps: sourceDocument.conceptMaps.filter((reference) => Number(reference.rootItemId) !== rootId && !descendantIds.has(Number(reference.rootItemId))) }; const parentRelations = relationsFor(parentConceptIds); const parentOwnerships = ownershipsFor(parentConceptIds); if (parentRelations.length) parentDocument.conceptRelations = parentRelations; else delete parentDocument.conceptRelations; if (parentOwnerships.length) parentDocument.conceptOwnerships = parentOwnerships; else delete parentDocument.conceptOwnerships; return { parentModel: CmapModel.fromDocument(parentDocument), childModel: CmapModel.fromDocument(childDocument) }; } toDocument() { const document = this.conceptMap.toDocument(); const usedConceptIds = new Set(this.conceptMap.items() .filter((item) => item instanceof ConceptMapConcept) .map((item) => item.conceptId)); document.concepts = this.repository.toDocument() .filter((concept) => usedConceptIds.has(concept.id)); const relations = this.repository.relationDocuments(); const ownerships = this.repository.ownershipDocuments(); if (relations.length) document.conceptRelations = relations; if (ownerships.length) document.conceptOwnerships = ownerships; return document; } } export { PLACEMENT_FIELDS };