|
11 | 11 | --shadow: 0 6px 18px rgba(15, 23, 42, 0.08); |
12 | 12 | --line-color: #111827; |
13 | 13 | --line-width: 2.5; |
| 14 | + --legend-core-color: #d86b5f; |
| 15 | + --legend-geo-color: #2d8d9f; |
14 | 16 | } |
15 | 17 |
|
16 | 18 | * { |
|
31 | 33 | position: relative; |
32 | 34 | width: var(--page-width); |
33 | 35 | min-height: 420px; |
34 | | - padding: 12px; |
| 36 | + padding: 12px 12px 44px; |
35 | 37 | } |
36 | 38 |
|
37 | 39 | .diagram-loading { |
|
256 | 258 | } |
257 | 259 |
|
258 | 260 | .legend-box { |
259 | | - position: absolute; |
260 | | - left: 20px; |
261 | | - bottom: 20px; |
262 | | - z-index: 4; |
263 | | - margin: 0; |
264 | | - width: 800px; |
265 | | - max-width: calc(100% - 40px); |
| 261 | + position: relative; |
| 262 | + z-index: 2; |
| 263 | + margin: 12px auto 0; |
| 264 | + width: 100%; |
| 265 | + max-width: 100%; |
266 | 266 | padding: 10px 12px; |
267 | 267 | border: 1px solid #d1d5db; |
268 | 268 | border-radius: 8px; |
|
286 | 286 | display: flex; |
287 | 287 | flex-wrap: wrap; |
288 | 288 | align-items: center; |
289 | | - gap: 8px 12px; |
| 289 | + justify-content: space-evenly; |
| 290 | + row-gap: 10px; |
| 291 | + column-gap: 20px; |
290 | 292 | } |
291 | 293 |
|
292 | 294 | .legend-item { |
|
354 | 356 | } |
355 | 357 |
|
356 | 358 | .legend-swatch--core { |
357 | | - background: #d86b5f; |
| 359 | + background: var(--legend-core-color); |
358 | 360 | } |
359 | 361 |
|
360 | 362 | .legend-swatch--geo { |
361 | | - background: #2d8d9f; |
| 363 | + background: var(--legend-geo-color); |
362 | 364 | } |
363 | 365 |
|
364 | 366 | @media (max-width: 1200px) { |
|
384 | 386 | } |
385 | 387 |
|
386 | 388 | .legend-box { |
387 | | - width: min(440px, calc(100% - 24px)); |
388 | | - left: 12px; |
389 | | - bottom: 12px; |
| 389 | + width: 100%; |
| 390 | + max-width: 100%; |
390 | 391 | } |
391 | 392 | } |
392 | 393 |
|
|
431 | 432 | } |
432 | 433 |
|
433 | 434 | .legend-box { |
434 | | - width: 440px !important; |
435 | | - max-width: calc(100% - 24px) !important; |
436 | | - left: 12px !important; |
437 | | - bottom: 12px !important; |
| 435 | + width: 100% !important; |
| 436 | + max-width: 100% !important; |
| 437 | + margin: 12px auto 0 !important; |
438 | 438 | } |
439 | 439 | } |
440 | 440 | </style> |
|
451 | 451 | </div> |
452 | 452 | <svg id="connector-layer" aria-hidden="true"></svg> |
453 | 453 | <div id="tables-grid"></div> |
| 454 | + </section> |
| 455 | + <div class="col-md-10 col-lg-8 mx-auto"> |
454 | 456 | <aside id="diagram-legend" class="legend-box" aria-label="Diagram legend" aria-hidden="true"> |
455 | 457 | <p class="legend-title">Legend</p> |
456 | 458 | <div class="legend-items"> |
|
476 | 478 | <span class="legend-arrow" aria-hidden="true"></span> |
477 | 479 | <span class="legend-label">Foreign key relationship</span> |
478 | 480 | </div> |
479 | | - <div class="legend-item"> |
480 | | - <span class="legend-swatch legend-swatch--core" aria-hidden="true"></span> |
481 | | - <span class="legend-label">Core tables</span> |
482 | | - </div> |
483 | | - <div class="legend-item"> |
484 | | - <span class="legend-swatch legend-swatch--geo" aria-hidden="true"></span> |
485 | | - <span class="legend-label">GeoPressureR tables</span> |
486 | | - </div> |
487 | 481 | </div> |
488 | 482 | </aside> |
489 | | - </section> |
| 483 | + </div> |
490 | 484 | </div> |
491 | 485 | <script> |
492 | 486 | const CONFIG = { |
|
504 | 498 | // Array-of-arrays: each inner array is one column from top to bottom. |
505 | 499 | layout: [ |
506 | 500 | ["measurements", "observations"], |
507 | | - ["tags", "paths"], |
508 | | - ["twilights", "staps"], |
509 | | - ["pressurepaths", "edges"], |
| 501 | + ["tags"], |
| 502 | + ["pressurepaths", "staps", "paths"], |
| 503 | + ["twilights", "edges"], |
510 | 504 | ], |
511 | 505 | tableStyles: { |
512 | 506 | tags: { accent: "#cf5b7f" }, |
|
560 | 554 | ], |
561 | 555 | // Manual order for vertical trunk lanes; lower value sorts first. |
562 | 556 | trunkOrder: [ |
| 557 | + { |
| 558 | + sourceTable: "edges", |
| 559 | + sourceField: "stap_t", |
| 560 | + targetTable: "staps", |
| 561 | + targetField: "stap_id", |
| 562 | + order: -30, |
| 563 | + }, |
| 564 | + { |
| 565 | + sourceTable: "edges", |
| 566 | + sourceField: "stap_s", |
| 567 | + targetTable: "staps", |
| 568 | + targetField: "stap_id", |
| 569 | + order: -20, |
| 570 | + }, |
| 571 | + { |
| 572 | + sourceTable: "edges", |
| 573 | + sourceField: "tag_id", |
| 574 | + targetTable: "staps", |
| 575 | + targetField: "tag_id", |
| 576 | + order: -10, |
| 577 | + }, |
563 | 578 | { |
564 | 579 | sourceTable: "pressurepaths", |
565 | 580 | sourceField: "tag_id", |
|
575 | 590 | order: 10, |
576 | 591 | }, |
577 | 592 | ], |
| 593 | + // Manual trunk position between two layout columns (1-based column numbers). |
| 594 | + trunkBetweenColumns: [ |
| 595 | + { |
| 596 | + sourceTable: "twilights", |
| 597 | + sourceField: "tag_id", |
| 598 | + targetTable: "tags", |
| 599 | + targetField: "tag_id", |
| 600 | + leftColumn: 3, |
| 601 | + rightColumn: 4, |
| 602 | + }, |
| 603 | + ], |
| 604 | + // Manual order for endpoint attachment when multiple connectors share the same field side. |
| 605 | + endpointOrder: [ |
| 606 | + { |
| 607 | + sourceTable: "twilights", |
| 608 | + sourceField: "tag_id", |
| 609 | + targetTable: "tags", |
| 610 | + targetField: "tag_id", |
| 611 | + order: -10, |
| 612 | + }, |
| 613 | + ], |
578 | 614 | connectorStyle: { |
579 | 615 | // Marker size in SVG user units. Increase/decrease to tune arrow head size. |
580 | 616 | arrowHeadSize: 15, |
|
789 | 825 | return 0; |
790 | 826 | } |
791 | 827 |
|
| 828 | + function endpointOrderValue(relation) { |
| 829 | + for (const rule of CONFIG.endpointOrder || []) { |
| 830 | + if (matchRelationRule(relation, rule)) { |
| 831 | + return Number.isFinite(rule.order) ? rule.order : 0; |
| 832 | + } |
| 833 | + } |
| 834 | + return 0; |
| 835 | + } |
| 836 | + |
| 837 | + function trunkBetweenColumnsX(relation, columnRects, baseRect) { |
| 838 | + for (const rule of CONFIG.trunkBetweenColumns || []) { |
| 839 | + if (!matchRelationRule(relation, rule)) { |
| 840 | + continue; |
| 841 | + } |
| 842 | + const leftIndex = Number(rule.leftColumn) - 1; |
| 843 | + const rightIndex = Number(rule.rightColumn) - 1; |
| 844 | + if (!Number.isFinite(leftIndex) || !Number.isFinite(rightIndex)) { |
| 845 | + continue; |
| 846 | + } |
| 847 | + const leftRect = columnRects[leftIndex]; |
| 848 | + const rightRect = columnRects[rightIndex]; |
| 849 | + if (!leftRect || !rightRect) { |
| 850 | + continue; |
| 851 | + } |
| 852 | + const leftX = leftRect.right - baseRect.left; |
| 853 | + const rightX = rightRect.left - baseRect.left; |
| 854 | + return (leftX + rightX) / 2; |
| 855 | + } |
| 856 | + return null; |
| 857 | + } |
| 858 | + |
792 | 859 | async function fetchSchema(file) { |
793 | 860 | const url = `${CONFIG.schemaBaseUrl}/${file}`; |
794 | 861 | const res = await fetch(url); |
|
1255 | 1322 | } |
1256 | 1323 |
|
1257 | 1324 | group.sort((a, b) => { |
| 1325 | + const orderDiff = endpointOrderValue(a.edge.rel) - endpointOrderValue(b.edge.rel); |
| 1326 | + if (orderDiff !== 0) { |
| 1327 | + return orderDiff; |
| 1328 | + } |
1258 | 1329 | const yDiff = a.peerY - b.peerY; |
1259 | 1330 | if (yDiff !== 0) { |
1260 | 1331 | return yDiff; |
|
1281 | 1352 | const wrapper = document.getElementById("diagram-wrapper"); |
1282 | 1353 | const svg = document.getElementById("connector-layer"); |
1283 | 1354 | const baseRect = wrapper.getBoundingClientRect(); |
| 1355 | + const columnRects = [...document.querySelectorAll("#tables-grid .schema-column")].map((el) => |
| 1356 | + el.getBoundingClientRect(), |
| 1357 | + ); |
1284 | 1358 | const columnIndexByTable = getColumnIndexMap(); |
1285 | 1359 | const lineColor = |
1286 | 1360 | getComputedStyle(document.documentElement).getPropertyValue("--line-color").trim() || |
|
1291 | 1365 | const trunkPadding = 28; |
1292 | 1366 | const arrowHeadSize = Number(CONFIG.connectorStyle?.arrowHeadSize) || 3.2; |
1293 | 1367 | const arrowHeadPadding = Number(CONFIG.connectorStyle?.arrowHeadPadding) || 2; |
| 1368 | + const rootStyles = getComputedStyle(document.documentElement); |
| 1369 | + const coreBandColor = rootStyles.getPropertyValue("--legend-core-color").trim() || "#d86b5f"; |
| 1370 | + const geoBandColor = rootStyles.getPropertyValue("--legend-geo-color").trim() || "#2d8d9f"; |
1294 | 1371 |
|
1295 | 1372 | svg.setAttribute("viewBox", `0 0 ${Math.ceil(baseRect.width)} ${Math.ceil(baseRect.height)}`); |
1296 | 1373 | const defs = makeSvgElement("defs"); |
|
1317 | 1394 | defs.appendChild(arrowHead); |
1318 | 1395 | svg.appendChild(defs); |
1319 | 1396 |
|
| 1397 | + const midLeftRect = columnRects[1]; |
| 1398 | + const midRightRect = columnRects[2]; |
| 1399 | + const splitX = |
| 1400 | + midLeftRect && midRightRect |
| 1401 | + ? (midLeftRect.right + midRightRect.left) / 2 - baseRect.left |
| 1402 | + : null; |
| 1403 | + const bandTopPadding = 10; |
| 1404 | + const bandBottomLabelArea = 38; |
| 1405 | + const sharedTop = Math.min(...columnRects.map((rect) => rect.top)) - baseRect.top - bandTopPadding; |
| 1406 | + const tableBottom = Math.max(...columnRects.map((rect) => rect.bottom)) - baseRect.top; |
| 1407 | + const sharedBottom = tableBottom + bandBottomLabelArea; |
| 1408 | + const labelY = tableBottom + 27; |
| 1409 | + const columnBands = [ |
| 1410 | + { startIndex: 0, endIndex: 1, color: coreBandColor, label: "Core resources" }, |
| 1411 | + { startIndex: 2, endIndex: 3, color: geoBandColor, label: "GeoPressureR resources" }, |
| 1412 | + ]; |
| 1413 | + for (const [bandIndex, band] of columnBands.entries()) { |
| 1414 | + const startRect = columnRects[band.startIndex]; |
| 1415 | + const endRect = columnRects[band.endIndex]; |
| 1416 | + if (!startRect || !endRect) { |
| 1417 | + continue; |
| 1418 | + } |
| 1419 | + const left = |
| 1420 | + splitX != null && bandIndex === 1 ? splitX : startRect.left - baseRect.left - 10; |
| 1421 | + const right = |
| 1422 | + splitX != null && bandIndex === 0 ? splitX : endRect.right - baseRect.left + 10; |
| 1423 | + svg.appendChild( |
| 1424 | + makeSvgElement("rect", { |
| 1425 | + x: String(left), |
| 1426 | + y: String(sharedTop), |
| 1427 | + width: String(Math.max(0, right - left)), |
| 1428 | + height: String(Math.max(0, sharedBottom - sharedTop)), |
| 1429 | + fill: hexToRgba(band.color, 0.12) || "rgba(0,0,0,0.06)", |
| 1430 | + "pointer-events": "none", |
| 1431 | + }), |
| 1432 | + ); |
| 1433 | + svg.appendChild( |
| 1434 | + makeSvgElement("text", { |
| 1435 | + x: String((left + right) / 2), |
| 1436 | + y: String(labelY), |
| 1437 | + "text-anchor": "middle", |
| 1438 | + "font-size": "19", |
| 1439 | + "font-weight": "600", |
| 1440 | + fill: band.color, |
| 1441 | + "pointer-events": "none", |
| 1442 | + }), |
| 1443 | + ).textContent = band.label; |
| 1444 | + } |
| 1445 | + |
1320 | 1446 | const edges = []; |
1321 | 1447 | for (const rel of relations) { |
1322 | 1448 | const sourceTableSlug = slug(rel.sourceTable); |
|
1412 | 1538 | }, |
1413 | 1539 | laneSpacing, |
1414 | 1540 | (edge, offset) => { |
| 1541 | + const fixedTrunkX = trunkBetweenColumnsX(edge.rel, columnRects, baseRect); |
| 1542 | + if (Number.isFinite(fixedTrunkX)) { |
| 1543 | + edge.trunkX = fixedTrunkX + offset; |
| 1544 | + return; |
| 1545 | + } |
| 1546 | + |
1415 | 1547 | if (edge.sameColumn) { |
1416 | 1548 | if (edge.sourceSide === "right") { |
1417 | 1549 | const outerRight = |
|
0 commit comments