|
99 | 99 | </div>
|
100 | 100 | </div>
|
101 | 101 |
|
| 102 | + <div class="mb-8 px-4 lg:px-8 sm:px-6"> |
| 103 | + <div class="bg-white dark:bg-blue-gray-700 rounded-lg shadow"> |
| 104 | + <div class="p-6"> |
| 105 | + <div class="flex items-center justify-between mb-6"> |
| 106 | + <div> |
| 107 | + <h3 class="text-base font-medium text-gray-900 dark:text-gray-100">Subscriber Model Relationships</h3> |
| 108 | + <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Interactive diagram showing Subscriber model relationships</p> |
| 109 | + </div> |
| 110 | + </div> |
| 111 | + <div ref="diagramContainer" class="h-[400px] relative"> |
| 112 | + <!-- D3 diagram will be rendered here --> |
| 113 | + </div> |
| 114 | + </div> |
| 115 | + </div> |
| 116 | + </div> |
| 117 | + |
102 | 118 | <div class="px-4 pt-12 lg:px-8 sm:px-6">
|
103 | 119 | <div class="sm:flex sm:items-center">
|
104 | 120 | <div class="sm:flex-auto">
|
@@ -247,6 +263,8 @@ import {
|
247 | 263 | Scale,
|
248 | 264 | CoreScaleOptions,
|
249 | 265 | } from 'chart.js'
|
| 266 | +import * as d3 from 'd3' |
| 267 | +import { useRouter } from 'vue-router' |
250 | 268 |
|
251 | 269 | ChartJS.register(
|
252 | 270 | CategoryScale,
|
@@ -399,4 +417,226 @@ onMounted(async () => {
|
399 | 417 | await new Promise(resolve => setTimeout(resolve, 500))
|
400 | 418 | isLoading.value = false
|
401 | 419 | })
|
| 420 | +
|
| 421 | +// Get router instance |
| 422 | +const router = useRouter() |
| 423 | +
|
| 424 | +// Model node interface |
| 425 | +interface ModelNode extends d3.SimulationNodeDatum { |
| 426 | + id: string |
| 427 | + name: string |
| 428 | + properties: string[] |
| 429 | + relationships: string[] |
| 430 | + emoji: string |
| 431 | + color: string |
| 432 | + x?: number |
| 433 | + y?: number |
| 434 | + fx?: number | null |
| 435 | + fy?: number | null |
| 436 | +} |
| 437 | +
|
| 438 | +// Relationship link interface |
| 439 | +interface RelationshipLink { |
| 440 | + source: string | ModelNode |
| 441 | + target: string | ModelNode |
| 442 | + type: 'hasMany' | 'belongsTo' | 'hasOne' | 'belongsToMany' |
| 443 | +} |
| 444 | +
|
| 445 | +// Subscriber model and its relationships |
| 446 | +const models: ModelNode[] = [ |
| 447 | + { |
| 448 | + id: 'subscriber', |
| 449 | + name: 'Subscriber', |
| 450 | + properties: ['id', 'email', 'status'], |
| 451 | + relationships: ['subscriberEmails'], |
| 452 | + emoji: '📫', |
| 453 | + color: '#0D9488' |
| 454 | + }, |
| 455 | + { |
| 456 | + id: 'subscriberEmail', |
| 457 | + name: 'SubscriberEmail', |
| 458 | + properties: ['id', 'email', 'subscriber_id'], |
| 459 | + relationships: ['subscriber'], |
| 460 | + emoji: '✉️', |
| 461 | + color: '#2DD4BF' |
| 462 | + } |
| 463 | +] |
| 464 | +
|
| 465 | +// Define relationships |
| 466 | +const relationships: RelationshipLink[] = [ |
| 467 | + { source: 'subscriber', target: 'subscriberEmail', type: 'hasMany' }, |
| 468 | + { source: 'subscriberEmail', target: 'subscriber', type: 'belongsTo' } |
| 469 | +] |
| 470 | +
|
| 471 | +// Visualization state |
| 472 | +const diagramContainer = ref<HTMLElement | null>(null) |
| 473 | +let simulation: d3.Simulation<ModelNode, undefined> |
| 474 | +
|
| 475 | +// Function to get route path for a model |
| 476 | +const getModelRoute = (modelId: string) => { |
| 477 | + const routes: Record<string, string> = { |
| 478 | + user: '/models/users', |
| 479 | + team: '/models/teams', |
| 480 | + accessToken: '/models/access-tokens', |
| 481 | + activity: '/models/activities', |
| 482 | + post: '/models/posts', |
| 483 | + subscriber: '/models/subscribers', |
| 484 | + subscriberEmail: '/models/subscriber-emails' |
| 485 | + } |
| 486 | + return routes[modelId] || '/models' |
| 487 | +} |
| 488 | +
|
| 489 | +onMounted(() => { |
| 490 | + if (!diagramContainer.value) return |
| 491 | +
|
| 492 | + const width = 800 |
| 493 | + const height = 400 |
| 494 | + const svg = d3.select(diagramContainer.value) |
| 495 | + .append('svg') |
| 496 | + .attr('width', width) |
| 497 | + .attr('height', height) |
| 498 | + .attr('viewBox', [0, 0, width, height]) |
| 499 | + .attr('style', 'max-width: 100%; height: auto;') |
| 500 | +
|
| 501 | + // Create arrow marker |
| 502 | + svg.append('defs').selectAll('marker') |
| 503 | + .data(['arrow']) |
| 504 | + .join('marker') |
| 505 | + .attr('id', d => d) |
| 506 | + .attr('viewBox', '0 -5 10 10') |
| 507 | + .attr('refX', 25) |
| 508 | + .attr('refY', 0) |
| 509 | + .attr('markerWidth', 6) |
| 510 | + .attr('markerHeight', 6) |
| 511 | + .attr('orient', 'auto') |
| 512 | + .append('path') |
| 513 | + .attr('fill', '#999') |
| 514 | + .attr('d', 'M0,-5L10,0L0,5') |
| 515 | +
|
| 516 | + // Create the simulation |
| 517 | + simulation = d3.forceSimulation<ModelNode>(models) |
| 518 | + .force('link', d3.forceLink<ModelNode, RelationshipLink>(relationships) |
| 519 | + .id(d => d.id) |
| 520 | + .distance(150)) |
| 521 | + .force('charge', d3.forceManyBody().strength(-800)) |
| 522 | + .force('center', d3.forceCenter(width / 2, height / 2)) |
| 523 | +
|
| 524 | + // Draw the links |
| 525 | + const link = svg.append('g') |
| 526 | + .selectAll('line') |
| 527 | + .data(relationships) |
| 528 | + .join('line') |
| 529 | + .attr('stroke', '#999') |
| 530 | + .attr('stroke-opacity', 0.6) |
| 531 | + .attr('stroke-width', 2) |
| 532 | + .attr('marker-end', 'url(#arrow)') |
| 533 | +
|
| 534 | + // Draw the nodes with proper typing |
| 535 | + const node = svg.append('g') |
| 536 | + .selectAll<SVGGElement, ModelNode>('g') |
| 537 | + .data(models) |
| 538 | + .join('g') |
| 539 | + .call((selection) => { |
| 540 | + const drag = d3.drag<SVGGElement, ModelNode>() |
| 541 | + .on('start', dragstarted) |
| 542 | + .on('drag', dragged) |
| 543 | + .on('end', dragended) |
| 544 | + return drag(selection) |
| 545 | + }) |
| 546 | + .style('cursor', 'pointer') |
| 547 | + .on('click', (event, d) => { |
| 548 | + router.push(getModelRoute(d.id)) |
| 549 | + }) |
| 550 | +
|
| 551 | + // Add hover effect to nodes |
| 552 | + node.on('mouseover', function() { |
| 553 | + d3.select(this).select('circle') |
| 554 | + .transition() |
| 555 | + .duration(200) |
| 556 | + .attr('r', 22) // Slightly larger on hover |
| 557 | + }) |
| 558 | + .on('mouseout', function() { |
| 559 | + d3.select(this).select('circle') |
| 560 | + .transition() |
| 561 | + .duration(200) |
| 562 | + .attr('r', 20) // Back to normal size |
| 563 | + }) |
| 564 | +
|
| 565 | + // Add circles for nodes |
| 566 | + node.append('circle') |
| 567 | + .attr('r', 20) |
| 568 | + .attr('fill', d => d.color) |
| 569 | +
|
| 570 | + // Add emojis |
| 571 | + node.append('text') |
| 572 | + .attr('dy', '0.35em') |
| 573 | + .attr('text-anchor', 'middle') |
| 574 | + .text(d => d.emoji) |
| 575 | + .attr('font-size', '20px') |
| 576 | +
|
| 577 | + // Add labels |
| 578 | + node.append('text') |
| 579 | + .attr('dy', 35) |
| 580 | + .attr('text-anchor', 'middle') |
| 581 | + .text(d => d.name) |
| 582 | + .attr('fill', '#374151') |
| 583 | + .attr('font-size', '14px') |
| 584 | + .attr('font-weight', 'bold') |
| 585 | +
|
| 586 | + // Update positions on each tick |
| 587 | + simulation.on('tick', () => { |
| 588 | + link |
| 589 | + .attr('x1', d => (d.source as ModelNode).x!) |
| 590 | + .attr('y1', d => (d.source as ModelNode).y!) |
| 591 | + .attr('x2', d => (d.target as ModelNode).x!) |
| 592 | + .attr('y2', d => (d.target as ModelNode).y!) |
| 593 | +
|
| 594 | + node |
| 595 | + .attr('transform', d => `translate(${d.x},${d.y})`) |
| 596 | + }) |
| 597 | +
|
| 598 | + // Drag functions |
| 599 | + function dragstarted(event: d3.D3DragEvent<SVGGElement, ModelNode, ModelNode>) { |
| 600 | + if (!event.active) simulation.alphaTarget(0.3).restart() |
| 601 | + event.subject.fx = event.subject.x |
| 602 | + event.subject.fy = event.subject.y |
| 603 | + } |
| 604 | +
|
| 605 | + function dragged(event: d3.D3DragEvent<SVGGElement, ModelNode, ModelNode>) { |
| 606 | + event.subject.fx = event.x |
| 607 | + event.subject.fy = event.y |
| 608 | + } |
| 609 | +
|
| 610 | + function dragended(event: d3.D3DragEvent<SVGGElement, ModelNode, ModelNode>) { |
| 611 | + if (!event.active) simulation.alphaTarget(0) |
| 612 | + event.subject.fx = null |
| 613 | + event.subject.fy = null |
| 614 | + } |
| 615 | +}) |
402 | 616 | </script>
|
| 617 | + |
| 618 | +<style scoped> |
| 619 | +/* Add these styles for the diagram */ |
| 620 | +:deep(svg) { |
| 621 | + background-color: transparent; |
| 622 | + border-radius: 0.5rem; |
| 623 | +} |
| 624 | +
|
| 625 | +:deep(line) { |
| 626 | + stroke-linecap: round; |
| 627 | +} |
| 628 | +
|
| 629 | +:deep(text) { |
| 630 | + user-select: none; |
| 631 | +} |
| 632 | +
|
| 633 | +:deep(circle) { |
| 634 | + cursor: grab; |
| 635 | +} |
| 636 | +
|
| 637 | +:deep(circle:active) { |
| 638 | + cursor: grabbing; |
| 639 | +} |
| 640 | +
|
| 641 | +/* ... existing styles ... */ |
| 642 | +</style> |
0 commit comments