Skip to content

Feat/angular-roadmap-svg-zoom #429

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/blog/src/assets/icons/circle-center.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/blog/src/assets/icons/zoom-in.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/blog/src/assets/icons/zoom-out.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/blog/src/assets/icons/zoom-reset.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
234 changes: 124 additions & 110 deletions libs/blog/roadmap/feature-roadmap/src/lib/feature-roadmap.component.html
Original file line number Diff line number Diff line change
@@ -1,76 +1,111 @@
<svg
class="roadmap-container"
width="100%"
height="100%"
xmlns:svg="http://www.w3.org/1999/html"
>
<svg:foreignObject
style="transform: translateX(calc(50% - {{ layoutEl.clientWidth / 2 }}px))"
height="0"
width="0"
overflow="visible"
<div class="relative h-full w-full">
<svg
#roadmap
height="100%"
width="100%"
xmlns:svg="http://www.w3.org/1999/html"
>
<div
#layoutEl
class="relative flex h-fit w-fit translate-y-16 flex-col items-center gap-16"
<svg:foreignObject
overflow="visible"
style="transform: translateX(calc(50% - {{
layoutEl.clientWidth / 2
}}px))"
>
@for (layer of roadmapLayers(); track layer.parentNode.id) {
<div #layerEl class="flex w-full flex-col items-center gap-16">
@if (!$last) {
@let layerHeightWithGap = layerEl.clientHeight + 64 - 24 || 0;
<svg
[attr.height]="layerHeightWithGap"
style="position: absolute"
overflow="visible"
width="100"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<marker
id="arrowhead"
markerWidth="6"
markerHeight="8"
refX="0"
refY="4"
orient="auto"
>
<polygon points="0 0, 6 4, 0 8" fill="#FDF5FD" />
</marker>
</defs>
<line
[attr.y2]="layerHeightWithGap"
x1="50"
y1="0"
x2="50"
stroke="white"
stroke-width="4"
marker-end="url(#arrowhead)"
/>
</svg>
}
<div
#layoutEl
class="relative flex h-fit w-fit translate-y-16 flex-col items-center gap-16"
>
@for (layer of roadmapLayers(); track layer.parentNode.id) {
<div #layerEl class="flex w-full flex-col items-center gap-16">
@if (!$last) {
@let layerHeightWithGap = layerEl.clientHeight + 64 - 24 || 0;
<svg
[attr.height]="layerHeightWithGap"
style="position: absolute"
overflow="visible"
width="100"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<marker
id="arrowhead"
markerWidth="6"
markerHeight="8"
refX="0"
refY="4"
orient="auto"
>
<polygon points="0 0, 6 4, 0 8" fill="#FDF5FD" />
</marker>
</defs>
<line
[attr.y2]="layerHeightWithGap"
x1="50"
y1="0"
x2="50"
stroke="white"
stroke-width="4"
marker-end="url(#arrowhead)"
/>
</svg>
}

<!-- layer parent node-->
@if (layer.parentNode.nodeType === 'primary') {
<al-ui-roadmap-primary-node [node]="layer.parentNode" />
} @else if (layer.parentNode.nodeType === 'angular-love') {
<al-ui-roadmap-angular-love-node
[node]="layer.parentNode"
></al-ui-roadmap-angular-love-node>
}
<!-- layer parent node-->
@if (layer.parentNode.nodeType === 'primary') {
<al-ui-roadmap-primary-node [node]="layer.parentNode" />
} @else if (layer.parentNode.nodeType === 'angular-love') {
<al-ui-roadmap-angular-love-node
[node]="layer.parentNode"
></al-ui-roadmap-angular-love-node>
}

<!-- layer child nodes-->
@if (layer.childNodes?.length) {
@let shift =
(allChildNodesEl.clientWidth / 2 - leftChildNodesEl.clientWidth ||
0) - 32;
<div
#allChildNodesEl
class="flex gap-16"
style="transform: translate({{ shift }}px)"
>
<div #leftChildNodesEl class="flex gap-12">
@let centerShift = layerEl.clientWidth / 2 - shift;
@for (node of layer.childNodes | leftSlice; track node.id) {
<!-- layer child nodes-->
@if (layer.childNodes?.length) {
@let shift =
(allChildNodesEl.clientWidth / 2 -
leftChildNodesEl.clientWidth || 0) - 32;
<div
#allChildNodesEl
class="flex gap-16"
style="transform: translate({{ shift }}px)"
>
<div #leftChildNodesEl class="flex gap-12">
@let centerShift = layerEl.clientWidth / 2 - shift;
@for (node of layer.childNodes | leftSlice; track node.id) {
@let arrowShift =
secondaryNode.offsetLeft -
centerShift +
secondaryNode.clientWidth / 2;
<svg
[attr.height]="64"
style="position: absolute; top: -64px; left: {{
centerShift
}}px"
overflow="visible"
width="1"
xmlns="http://www.w3.org/2000/svg"
>
<svg:path
[attr.d]="arrowShift | secondaryArrow: $first"
fill="none"
stroke="#FDF5FD"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<div #secondaryNode>
@if (node.nodeType === 'secondary') {
<al-ui-roadmap-secondary-node [node]="node" />
} @else if (node.nodeType === 'cluster') {
<al-ui-roadmap-cluster
[cluster]="$any(node)"
></al-ui-roadmap-cluster>
}
</div>
}
</div>
@for (node of layer.childNodes | rightSlice; track node.id) {
@let arrowShift =
secondaryNode.offsetLeft -
centerShift +
Expand All @@ -85,7 +120,7 @@
xmlns="http://www.w3.org/2000/svg"
>
<svg:path
[attr.d]="arrowShift | secondaryArrow: $first"
[attr.d]="arrowShift | secondaryArrow: $last"
fill="none"
stroke="#FDF5FD"
stroke-width="2"
Expand All @@ -104,43 +139,22 @@
</div>
}
</div>
@for (node of layer.childNodes | rightSlice; track node.id) {
@let arrowShift =
secondaryNode.offsetLeft -
centerShift +
secondaryNode.clientWidth / 2;
<svg
[attr.height]="64"
style="position: absolute; top: -64px; left: {{
centerShift
}}px"
overflow="visible"
width="1"
xmlns="http://www.w3.org/2000/svg"
>
<svg:path
[attr.d]="arrowShift | secondaryArrow: $last"
fill="none"
stroke="#FDF5FD"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<div #secondaryNode>
@if (node.nodeType === 'secondary') {
<al-ui-roadmap-secondary-node [node]="node" />
} @else if (node.nodeType === 'cluster') {
<al-ui-roadmap-cluster
[cluster]="$any(node)"
></al-ui-roadmap-cluster>
}
</div>
}
</div>
}
</div>
}
</div>
</svg:foreignObject>
</svg>
}
</div>
}
</div>
</svg:foreignObject>
</svg>
<div
class="absolute bottom-4 right-4 flex h-fit w-fit flex-col items-center gap-8"
>
@for (control of controls; track $index) {
<al-ui-roadmap-svg-control
[size]="control.size"
[event]="control.event"
[iconName]="control.name"
(resizeRoadmap)="resizeRoadmap($event)"
/>
}
</div>
</div>
Original file line number Diff line number Diff line change
@@ -1,19 +1,35 @@
import { isPlatformBrowser } from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
inject,
PLATFORM_ID,
signal,
viewChild,
} from '@angular/core';
import { FastSvgComponent } from '@push-based/ngx-fast-svg';

import { SecondaryArrowPipe } from './secondary-arrow.pipe';
import { LeftSlicePipe, RightSlicePipe } from './slice.pipes';
import { UiRoadmapAngularLoveNodeComponent } from './ui/ui-roadmap-angular-love-node.component';
import { UiRoadmapClusterComponent } from './ui/ui-roadmap-cluster.component';
import { UiRoadmapPrimaryNodeComponent } from './ui/ui-roadmap-primary-node.component';
import { UiRoadmapSecondaryNodeComponent } from './ui/ui-roadmap-secondary-node.component';
import { UiRoadmapSvgControlComponent } from './ui/ui-roadmap-svg-control.component';

export type NodeType = 'primary' | 'secondary' | 'cluster' | 'angular-love';

export type EventType = 'increment' | 'decrement' | 'reset' | 'zoom-reset';

export interface Control {
size: string;
name: string;
event: EventType;
}

export interface RoadmapNodeDTO {
id: string;
previousNodeId?: string;
Expand All @@ -37,6 +53,14 @@ export interface RoadmapLayer {
childNodes: RoadmapNode[];
}

const svgPanZoomInitialConfig = {
fit: false,
center: false,
minZoom: 0.5,
maxZoom: 2.5,
zoomScaleSensitivity: 0.1,
};

@Component({
selector: 'al-feature-roadmap',
imports: [
Expand All @@ -46,13 +70,19 @@ export interface RoadmapLayer {
UiRoadmapPrimaryNodeComponent,
UiRoadmapAngularLoveNodeComponent,
UiRoadmapSecondaryNodeComponent,
UiRoadmapSvgControlComponent,
SecondaryArrowPipe,
FastSvgComponent,
],
templateUrl: './feature-roadmap.component.html',
styleUrl: './feature-roadmap.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FeatureRoadmapComponent {
export class FeatureRoadmapComponent implements AfterViewInit {
private readonly _platform = inject(PLATFORM_ID);
private _svgPanZoom!: SvgPanZoom.Instance;
private readonly _svgRoadmap = viewChild<ElementRef<SVGElement>>('roadmap');

private readonly nodesDto = signal<RoadmapNodeDTO[]>([
{
id: '2',
Expand Down Expand Up @@ -98,6 +128,42 @@ export class FeatureRoadmapComponent {
},
]);

protected readonly controls: Control[] = [
{
event: 'increment',
size: '24',
name: 'zoom-in',
},
{
event: 'reset',
size: '24',
name: 'circle-center',
},
{
event: 'zoom-reset',
size: '24',
name: 'zoom-reset',
},
{
event: 'decrement',
size: '24',
name: 'zoom-out',
},
];

async ngAfterViewInit() {
if (isPlatformBrowser(this._platform)) {
await this.initSvgPanZoom();
}
}

resizeRoadmap(event: EventType): void {
if (event === 'reset') this._svgPanZoom.reset();
if (event === 'decrement') this._svgPanZoom.zoomOut();
if (event === 'increment') this._svgPanZoom.zoomIn();
if (event === 'zoom-reset') this._svgPanZoom.resetZoom();
}

protected readonly roadmapLayers = computed<RoadmapLayer[]>(() => {
const nodeDtoMap = this.nodesDto().reduce(
(acc, node) => ({ ...acc, [node.id]: node }),
Expand Down Expand Up @@ -208,4 +274,17 @@ export class FeatureRoadmapComponent {
...layers,
];
});

private async initSvgPanZoom() {
const svgPanZoomModule = await import('svg-pan-zoom');
const svgPanZoom: SvgPanZoom.Instance =
(svgPanZoomModule as any)['default'] || svgPanZoomModule;

const svgRoadmap = this._svgRoadmap();
if (svgRoadmap) {
this._svgPanZoom = svgPanZoom(svgRoadmap.nativeElement, {
...svgPanZoomInitialConfig,
});
}
}
}
Loading