505 lines
19 KiB
JavaScript
505 lines
19 KiB
JavaScript
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 };
|