refactoring van de cmap structuren bijna compleet
This commit is contained in:
@@ -0,0 +1,504 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user