feat: Implement intersection calculation for UML diagram node links

This commit is contained in:
kempersc 2025-11-27 10:58:45 +01:00
parent e99b1e644e
commit a6cbce1749
2 changed files with 342 additions and 138 deletions

View file

@ -1665,6 +1665,10 @@
opmerkingen_inez: Geen eigen KvK inschrijving.
samenwerkingsverband_platform: Collectie Gelderland
museum_register: ja
wikidata_id: Q2693553
type:
- M
- F
- plaatsnaam_bezoekadres: Arnhem
straat_en_huisnummer_bezoekadres: Zijpendaalseweg 44
organisatie: Kasteel Ammersoyen
@ -1674,6 +1678,10 @@
opmerkingen_inez: Geen eigen KvK inschrijving.
samenwerkingsverband_platform: Collectie Gelderland
museum_register: ja
wikidata_id: Q2447969
type:
- M
- F
- plaatsnaam_bezoekadres: Arnhem
straat_en_huisnummer_bezoekadres: Zijpendaalseweg 44
organisatie: Kasteel Cannenburch
@ -1682,6 +1690,10 @@
type_organisatie: opengesteld monument
opmerkingen_inez: Geen eigen KvK inschrijving.
museum_register: ja
wikidata_id: Q2175336
type:
- M
- F
- plaatsnaam_bezoekadres: Doornenburg
straat_en_huisnummer_bezoekadres: Kerkstraat 27
organisatie: Stichting tot Behoud van den Doornenburg
@ -1693,6 +1705,10 @@
museum_register: ja
in_scope_voor_dc4eu: ja
dc4eu_aansluit_route: via CN set=Collectie Gelderland
wikidata_id: Q2012221
type:
- M
- F
- plaatsnaam_bezoekadres: Arnhem
straat_en_huisnummer_bezoekadres: Zijpendaalseweg 44
organisatie: Kasteel Doorwerth
@ -1703,6 +1719,10 @@
een belangenvereniging maar zijn niet de beheerders.
samenwerkingsverband_platform: Collectie Gelderland
museum_register: ja
wikidata_id: Q680425
type:
- M
- F
- plaatsnaam_bezoekadres: Arnhem
straat_en_huisnummer_bezoekadres: Zijpendaalseweg 44
organisatie: Kasteel Hernen
@ -1712,6 +1732,10 @@
opmerkingen_inez: Geen eigen KvK inschrijving.
samenwerkingsverband_platform: Collectie Gelderland
museum_register: ja
wikidata_id: Q4322946
type:
- M
- F
- plaatsnaam_bezoekadres: Arnhem
straat_en_huisnummer_bezoekadres: Zijpendaalseweg 44
organisatie: Kasteel Rosendael
@ -1721,6 +1745,10 @@
opmerkingen_inez: Geen eigen KvK inschrijving.
samenwerkingsverband_platform: Collectie Gelderland
museum_register: ja
wikidata_id: Q2047615
type:
- M
- F
- plaatsnaam_bezoekadres: Nijmegen
straat_en_huisnummer_bezoekadres: Erasmuslaan 36
organisatie: Katholiek Documentatiecentrum in Nijmegen
@ -1729,6 +1757,9 @@
type_organisatie: documentatiecentrum
opmerkingen_inez: Geen eigen KvK inschrijving.
isil-code_na: NL-NmKDC
wikidata_id: Q13742228
type:
- A
- plaatsnaam_bezoekadres: Twello
straat_en_huisnummer_bezoekadres: Dorpsstraat 11a
organisatie: Historische Vereniging Voorst
@ -1738,6 +1769,9 @@
collectie_nederland: ja
in_scope_voor_dc4eu: ja
dc4eu_aansluit_route: via CN set=Collectie Gelderland
wikidata_id: Q98895215
type:
- S
- plaatsnaam_bezoekadres: Arnhem
straat_en_huisnummer_bezoekadres: Velperweg 147
organisatie: Koninklijk Tehuis voor Oud-Militairen en Museum Bronbeek
@ -1749,12 +1783,17 @@
rijkscollectie: ja
linked_data: ja
datasetregister: Stamboeken (colonialcollections)
wikidata_id: Q61930724
type:
- M
- plaatsnaam_bezoekadres: Apeldoorn
straat_en_huisnummer_bezoekadres: St Eustatius 18
organisatie: Stichting Korpora
webadres_organisatie: https://www.korpora.nl/
type_organisatie: kenniscentrum
museum_register: ja
type:
- S
- plaatsnaam_bezoekadres: Otterlo
straat_en_huisnummer_bezoekadres: Houtkampweg 6
organisatie: Stichting Kröller-Müller Museum
@ -1766,12 +1805,18 @@
museum_register: ja
rijkscollectie: ja
van_gogh_worldwide: ja
wikidata_id: Q1051928
type:
- M
- plaatsnaam_bezoekadres: Arnhem
straat_en_huisnummer_bezoekadres: Markt 11
organisatie: provincie Gelderland
webadres_organisatie: https://www.gelderland.nl/themas/organisatie/over-de-provincie/huis-der-provincie/provinciale-kunstcollectie
type_organisatie: provincie
samenwerkingsverband_platform: Collectie Gelderland
wikidata_id: Q775
type:
- O
- plaatsnaam_bezoekadres: Buren
organisatie: Marechausseemuseum
type_organisatie: museum
@ -1779,6 +1824,9 @@
Nationaal Militair Museum in Soesterberg.
museum_register: ja
rijkscollectie: ja
wikidata_id: Q18558954
type:
- M
- plaatsnaam_bezoekadres: Arnhem
straat_en_huisnummer_bezoekadres: Utrechtseweg 87
organisatie: Stichting Museum Arnhem
@ -1790,6 +1838,9 @@
museum_register: ja
modemuze: ja
delfts_aardewerk: ja
wikidata_id: Q2114028
type:
- M
- plaatsnaam_bezoekadres: Bennekom
straat_en_huisnummer_bezoekadres: Kerkstraat 1
organisatie: Kijk- en Luistermuseum
@ -1800,12 +1851,18 @@
museum_register: ja
in_scope_voor_dc4eu: ja
dc4eu_aansluit_route: via CN set=Collectie Gelderland
wikidata_id: Q13743314
type:
- M
- plaatsnaam_bezoekadres: Wageningen
straat_en_huisnummer_bezoekadres: Bowlespark 1A
organisatie: Stichting Historisch Museum De Casteelse Poort
webadres_organisatie: https://www.casteelsepoort.nl/
type_organisatie: museum
museum_register: ja
wikidata_id: Q2546552
type:
- M
- plaatsnaam_bezoekadres: Elburg
straat_en_huisnummer_bezoekadres: Jufferenstraat 6-8
organisatie: Stichting Museum Elburg
@ -1814,6 +1871,9 @@
samenwerkingsverband_platform: Collectie Gelderland
collectie_nederland: ja
museum_register: ja
wikidata_id: Q56459645
type:
- M
- plaatsnaam_bezoekadres: Zutphen
straat_en_huisnummer_bezoekadres: s Gravenhof 4
organisatie: Stichting Musea Zutphen (Stedelijk Museum Zutphen en Museum Henriette
@ -1822,7 +1882,9 @@
type_organisatie: museum
samenwerkingsverband_platform: Collectie Gelderland
museum_register: ja
wikidata_id: Q56423939
type:
- M
- plaatsnaam_bezoekadres: Wijchen
straat_en_huisnummer_bezoekadres: Kasteellaan 9
organisatie: Stichting Museum Kasteel Wijchen

View file

@ -265,6 +265,72 @@ export const UMLVisualization: React.FC<UMLVisualizationProps> = ({
const methodHeight = 24;
const nodePadding = 10;
// Helper function to calculate intersection point of a line with a rectangular box border
// Given a line from (x1,y1) to (x2,y2) and a rectangle centered at (cx,cy) with width w and height h,
// returns the point where the line intersects the rectangle border (from center outward)
const getBoxBorderIntersection = (
cx: number, cy: number, // Rectangle center
w: number, h: number, // Rectangle width and height
targetX: number, targetY: number // Target point (other end of line)
): { x: number; y: number } => {
const dx = targetX - cx;
const dy = targetY - cy;
// If target is at center, return center
if (dx === 0 && dy === 0) {
return { x: cx, y: cy };
}
const halfW = w / 2;
const halfH = h / 2;
// Calculate intersection with each edge and find the closest one
// The line exits through the edge that has the smallest positive t value
let t = Infinity;
// Right edge (x = cx + halfW)
if (dx > 0) {
const tRight = halfW / dx;
if (tRight < t && Math.abs(dy * tRight) <= halfH) {
t = tRight;
}
}
// Left edge (x = cx - halfW)
if (dx < 0) {
const tLeft = -halfW / dx;
if (tLeft < t && Math.abs(dy * tLeft) <= halfH) {
t = tLeft;
}
}
// Bottom edge (y = cy + halfH)
if (dy > 0) {
const tBottom = halfH / dy;
if (tBottom < t && Math.abs(dx * tBottom) <= halfW) {
t = tBottom;
}
}
// Top edge (y = cy - halfH)
if (dy < 0) {
const tTop = -halfH / dy;
if (tTop < t && Math.abs(dx * tTop) <= halfW) {
t = tTop;
}
}
// If no valid intersection found (shouldn't happen), return center
if (t === Infinity) {
return { x: cx, y: cy };
}
return {
x: cx + dx * t,
y: cy + dy * t
};
};
diagram.nodes.forEach(node => {
const attributeCount = node.attributes?.length || 0;
const methodCount = node.methods?.length || 0;
@ -403,130 +469,7 @@ export const UMLVisualization: React.FC<UMLVisualizationProps> = ({
link.isReversed = link.isReversed || false;
});
// Draw nodes FIRST (so they appear behind links/arrows in SVG z-order)
// In SVG, elements drawn later appear on top - we want arrows visible above node boxes
const nodes = g.append('g')
.attr('class', 'nodes')
.selectAll('g')
.data(diagram.nodes)
.join('g')
.attr('class', (d) => `node node-${d.type}`)
.call(d3.drag<any, any>()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended) as any);
// Node background
nodes.append('rect')
.attr('class', 'node-rect')
.attr('width', (d) => d.width || nodeWidth)
.attr('height', (d) => d.height || nodeHeaderHeight)
.attr('rx', 8)
.attr('fill', 'white')
.attr('stroke', (d) => d.type === 'enum' ? '#ffc107' : '#0a3dfa')
.attr('stroke-width', 2)
.on('click', (event, d) => {
event.stopPropagation();
setSelectedNode(d);
});
// Node header background
nodes.append('rect')
.attr('class', 'node-header')
.attr('width', (d) => d.width || nodeWidth)
.attr('height', nodeHeaderHeight)
.attr('rx', 8)
.attr('fill', (d) => d.type === 'enum' ? '#ffc107' : '#0a3dfa')
.attr('opacity', 0.1);
// Node name
nodes.append('text')
.attr('class', 'node-name')
.attr('x', (d) => (d.width || nodeWidth) / 2)
.attr('y', nodeHeaderHeight / 2)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr('fill', '#172a59')
.attr('font-weight', 'bold')
.attr('font-size', '14px')
.text((d) => d.name);
// Node type badge
nodes.append('text')
.attr('class', 'node-type')
.attr('x', 8)
.attr('y', 12)
.attr('fill', '#666')
.attr('font-size', '10px')
.attr('font-style', 'italic')
.text((d) => `«${d.type}»`);
// Draw attributes section
nodes.each(function(d) {
if (!d.attributes || d.attributes.length === 0) return;
const nodeGroup = d3.select(this);
let yOffset = nodeHeaderHeight + nodePadding;
// Attributes divider
nodeGroup.append('line')
.attr('x1', 0)
.attr('y1', nodeHeaderHeight)
.attr('x2', d.width || nodeWidth)
.attr('y2', nodeHeaderHeight)
.attr('stroke', '#0a3dfa')
.attr('stroke-width', 1);
// Attribute entries
d.attributes.forEach((attr, i) => {
nodeGroup.append('text')
.attr('class', 'node-attribute')
.attr('x', 10)
.attr('y', yOffset + i * attributeHeight)
.attr('fill', '#172a59')
.attr('font-size', '12px')
.text(`${attr.name}: ${attr.type}`);
});
yOffset += d.attributes.length * attributeHeight;
});
// Draw methods section
nodes.each(function(d) {
if (!d.methods || d.methods.length === 0) return;
const nodeGroup = d3.select(this);
const attributeCount = d.attributes?.length || 0;
let yOffset = nodeHeaderHeight + nodePadding +
(attributeCount > 0 ? attributeCount * attributeHeight + nodePadding : 0);
// Methods divider
nodeGroup.append('line')
.attr('x1', 0)
.attr('y1', yOffset - nodePadding)
.attr('x2', d.width || nodeWidth)
.attr('y2', yOffset - nodePadding)
.attr('stroke', '#0a3dfa')
.attr('stroke-width', 1);
// Method entries
d.methods.forEach((method, i) => {
const methodText = method.returnType
? `${method.name}(): ${method.returnType}`
: `${method.name}()`;
nodeGroup.append('text')
.attr('class', 'node-method')
.attr('x', 10)
.attr('y', yOffset + i * methodHeight)
.attr('fill', '#172a59')
.attr('font-size', '12px')
.text(methodText);
});
});
// Draw links AFTER nodes (so they appear on top in SVG z-order)
// This ensures arrows are visible above node boxes
// Draw links first (edges between nodes)
const links = g.append('g')
.attr('class', 'links')
.selectAll('g')
@ -665,14 +608,164 @@ export const UMLVisualization: React.FC<UMLVisualizationProps> = ({
return label;
});
// Draw nodes (on top of links so boxes appear above edge lines)
const nodes = g.append('g')
.attr('class', 'nodes')
.selectAll('g')
.data(diagram.nodes)
.join('g')
.attr('class', (d) => `node node-${d.type}`)
.call(d3.drag<any, any>()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended) as any);
// Node background
nodes.append('rect')
.attr('class', 'node-rect')
.attr('width', (d) => d.width || nodeWidth)
.attr('height', (d) => d.height || nodeHeaderHeight)
.attr('rx', 8)
.attr('fill', 'white')
.attr('stroke', (d) => d.type === 'enum' ? '#ffc107' : '#0a3dfa')
.attr('stroke-width', 2)
.on('click', (event, d) => {
event.stopPropagation();
setSelectedNode(d);
});
// Node header background
nodes.append('rect')
.attr('class', 'node-header')
.attr('width', (d) => d.width || nodeWidth)
.attr('height', nodeHeaderHeight)
.attr('rx', 8)
.attr('fill', (d) => d.type === 'enum' ? '#ffc107' : '#0a3dfa')
.attr('opacity', 0.1);
// Node name
nodes.append('text')
.attr('class', 'node-name')
.attr('x', (d) => (d.width || nodeWidth) / 2)
.attr('y', nodeHeaderHeight / 2)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr('fill', '#172a59')
.attr('font-weight', 'bold')
.attr('font-size', '14px')
.text((d) => d.name);
// Node type badge
nodes.append('text')
.attr('class', 'node-type')
.attr('x', 8)
.attr('y', 12)
.attr('fill', '#666')
.attr('font-size', '10px')
.attr('font-style', 'italic')
.text((d) => `«${d.type}»`);
// Draw attributes section
nodes.each(function(d) {
if (!d.attributes || d.attributes.length === 0) return;
const nodeGroup = d3.select(this);
let yOffset = nodeHeaderHeight + nodePadding;
// Attributes divider
nodeGroup.append('line')
.attr('x1', 0)
.attr('y1', nodeHeaderHeight)
.attr('x2', d.width || nodeWidth)
.attr('y2', nodeHeaderHeight)
.attr('stroke', '#0a3dfa')
.attr('stroke-width', 1);
// Attribute entries
d.attributes.forEach((attr, i) => {
nodeGroup.append('text')
.attr('class', 'node-attribute')
.attr('x', 10)
.attr('y', yOffset + i * attributeHeight)
.attr('fill', '#172a59')
.attr('font-size', '12px')
.text(`${attr.name}: ${attr.type}`);
});
yOffset += d.attributes.length * attributeHeight;
});
// Draw methods section
nodes.each(function(d) {
if (!d.methods || d.methods.length === 0) return;
const nodeGroup = d3.select(this);
const attributeCount = d.attributes?.length || 0;
let yOffset = nodeHeaderHeight + nodePadding +
(attributeCount > 0 ? attributeCount * attributeHeight + nodePadding : 0);
// Methods divider
nodeGroup.append('line')
.attr('x1', 0)
.attr('y1', yOffset - nodePadding)
.attr('x2', d.width || nodeWidth)
.attr('y2', yOffset - nodePadding)
.attr('stroke', '#0a3dfa')
.attr('stroke-width', 1);
// Method entries
d.methods.forEach((method, i) => {
const methodText = method.returnType
? `${method.name}(): ${method.returnType}`
: `${method.name}()`;
nodeGroup.append('text')
.attr('class', 'node-method')
.attr('x', 10)
.attr('y', yOffset + i * methodHeight)
.attr('fill', '#172a59')
.attr('font-size', '12px')
.text(methodText);
});
});
// Update positions on tick (force simulation) or immediately (dagre)
if (simulation) {
simulation.on('tick', () => {
// Calculate edge endpoints at box borders for force simulation
links.select('line')
.attr('x1', (d: any) => d.source.x)
.attr('y1', (d: any) => d.source.y)
.attr('x2', (d: any) => d.target.x)
.attr('y2', (d: any) => d.target.y);
.attr('x1', (d: any) => {
const sourceW = d.source.width || nodeWidth;
const sourceH = d.source.height || nodeHeaderHeight;
const intersection = getBoxBorderIntersection(
d.source.x, d.source.y, sourceW, sourceH, d.target.x, d.target.y
);
return intersection.x;
})
.attr('y1', (d: any) => {
const sourceW = d.source.width || nodeWidth;
const sourceH = d.source.height || nodeHeaderHeight;
const intersection = getBoxBorderIntersection(
d.source.x, d.source.y, sourceW, sourceH, d.target.x, d.target.y
);
return intersection.y;
})
.attr('x2', (d: any) => {
const targetW = d.target.width || nodeWidth;
const targetH = d.target.height || nodeHeaderHeight;
const intersection = getBoxBorderIntersection(
d.target.x, d.target.y, targetW, targetH, d.source.x, d.source.y
);
return intersection.x;
})
.attr('y2', (d: any) => {
const targetW = d.target.width || nodeWidth;
const targetH = d.target.height || nodeHeaderHeight;
const intersection = getBoxBorderIntersection(
d.target.x, d.target.y, targetW, targetH, d.source.x, d.source.y
);
return intersection.y;
});
links.select('text')
.attr('x', (d: any) => (d.source.x + d.target.x) / 2)
@ -682,22 +775,47 @@ export const UMLVisualization: React.FC<UMLVisualizationProps> = ({
});
} else {
// Dagre layout - positions are already computed, update immediately
// Calculate edge endpoints at box borders
links.select('line')
.attr('x1', (d: any) => {
const source = diagram.nodes.find(n => n.id === (typeof d.source === 'string' ? d.source : d.source.id));
return source?.x || 0;
const target = diagram.nodes.find(n => n.id === (typeof d.target === 'string' ? d.target : d.target.id));
if (!source || !target) return 0;
const intersection = getBoxBorderIntersection(
source.x!, source.y!, source.width || nodeWidth, source.height || nodeHeaderHeight,
target.x!, target.y!
);
return intersection.x;
})
.attr('y1', (d: any) => {
const source = diagram.nodes.find(n => n.id === (typeof d.source === 'string' ? d.source : d.source.id));
return source?.y || 0;
const target = diagram.nodes.find(n => n.id === (typeof d.target === 'string' ? d.target : d.target.id));
if (!source || !target) return 0;
const intersection = getBoxBorderIntersection(
source.x!, source.y!, source.width || nodeWidth, source.height || nodeHeaderHeight,
target.x!, target.y!
);
return intersection.y;
})
.attr('x2', (d: any) => {
const source = diagram.nodes.find(n => n.id === (typeof d.source === 'string' ? d.source : d.source.id));
const target = diagram.nodes.find(n => n.id === (typeof d.target === 'string' ? d.target : d.target.id));
return target?.x || 0;
if (!source || !target) return 0;
const intersection = getBoxBorderIntersection(
target.x!, target.y!, target.width || nodeWidth, target.height || nodeHeaderHeight,
source.x!, source.y!
);
return intersection.x;
})
.attr('y2', (d: any) => {
const source = diagram.nodes.find(n => n.id === (typeof d.source === 'string' ? d.source : d.source.id));
const target = diagram.nodes.find(n => n.id === (typeof d.target === 'string' ? d.target : d.target.id));
return target?.y || 0;
if (!source || !target) return 0;
const intersection = getBoxBorderIntersection(
target.x!, target.y!, target.width || nodeWidth, target.height || nodeHeaderHeight,
source.x!, source.y!
);
return intersection.y;
});
links.select('text')
@ -732,23 +850,47 @@ export const UMLVisualization: React.FC<UMLVisualizationProps> = ({
nodes.attr('transform', (node: any) =>
`translate(${node.x - (node.width || nodeWidth) / 2}, ${node.y - (node.height || nodeHeaderHeight) / 2})`
);
// Update links
// Update links with border intersection calculation
links.select('line')
.attr('x1', (link: any) => {
const source = diagram.nodes.find(n => n.id === (typeof link.source === 'string' ? link.source : link.source.id));
return source?.x || 0;
const target = diagram.nodes.find(n => n.id === (typeof link.target === 'string' ? link.target : link.target.id));
if (!source || !target) return 0;
const intersection = getBoxBorderIntersection(
source.x!, source.y!, source.width || nodeWidth, source.height || nodeHeaderHeight,
target.x!, target.y!
);
return intersection.x;
})
.attr('y1', (link: any) => {
const source = diagram.nodes.find(n => n.id === (typeof link.source === 'string' ? link.source : link.source.id));
return source?.y || 0;
const target = diagram.nodes.find(n => n.id === (typeof link.target === 'string' ? link.target : link.target.id));
if (!source || !target) return 0;
const intersection = getBoxBorderIntersection(
source.x!, source.y!, source.width || nodeWidth, source.height || nodeHeaderHeight,
target.x!, target.y!
);
return intersection.y;
})
.attr('x2', (link: any) => {
const source = diagram.nodes.find(n => n.id === (typeof link.source === 'string' ? link.source : link.source.id));
const target = diagram.nodes.find(n => n.id === (typeof link.target === 'string' ? link.target : link.target.id));
return target?.x || 0;
if (!source || !target) return 0;
const intersection = getBoxBorderIntersection(
target.x!, target.y!, target.width || nodeWidth, target.height || nodeHeaderHeight,
source.x!, source.y!
);
return intersection.x;
})
.attr('y2', (link: any) => {
const source = diagram.nodes.find(n => n.id === (typeof link.source === 'string' ? link.source : link.source.id));
const target = diagram.nodes.find(n => n.id === (typeof link.target === 'string' ? link.target : link.target.id));
return target?.y || 0;
if (!source || !target) return 0;
const intersection = getBoxBorderIntersection(
target.x!, target.y!, target.width || nodeWidth, target.height || nodeHeaderHeight,
source.x!, source.y!
);
return intersection.y;
});
}
}