423 lines
16 KiB
JavaScript
423 lines
16 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 = {
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Split an embedded submap into a standalone map and its remaining parent.
|
|
* 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 { parentDocument, 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 };
|