| 1 | "use client"; |
| 2 | |
| 3 | import { type Infographic, type InfographicOptions } from "@antv/infographic"; |
| 4 | |
| 5 | const TITLE_WIDTH_MULTIPLIER = 1.5; |
| 6 | const DESCRIPTION_WIDTH_MULTIPLIER = 1.5; |
| 7 | const MAX_TEXT_VIEWPORT_RATIO = 0.95; |
| 8 | const CENTER_ALIGN = "CENTER"; |
| 9 | |
| 10 | type InfographicElementUpdate = { |
| 11 | attributes: Record<string, string | number>; |
| 12 | }; |
| 13 | |
| 14 | type InfographicEditorState = { |
| 15 | updateElement: (element: Element, props: InfographicElementUpdate) => void; |
| 16 | }; |
| 17 | |
| 18 | type InfographicEditorContainer = { |
| 19 | editor?: { |
| 20 | state?: InfographicEditorState; |
| 21 | }; |
| 22 | }; |
| 23 | |
| 24 | export function hasInfographicTitleLayoutAttributes( |
| 25 | data?: Partial<InfographicOptions>, |
| 26 | ): boolean { |
| 27 | const attributes = data?.data?.attributes; |
| 28 | |
| 29 | return Boolean(attributes?.title || attributes?.desc); |
| 30 | } |
| 31 | |
| 32 | function parseNumberAttribute(value: string | null): number | null { |
| 33 | if (!value) return null; |
| 34 | |
| 35 | const parsed = Number.parseFloat(value); |
| 36 | return Number.isFinite(parsed) ? parsed : null; |
| 37 | } |
| 38 | |
| 39 | function getSvgViewportWidth(element: SVGGraphicsElement): number | null { |
| 40 | const svgElement = element.ownerSVGElement; |
| 41 | if (!svgElement) return null; |
| 42 | |
| 43 | const viewBoxWidth = svgElement.viewBox.baseVal.width; |
| 44 | if (viewBoxWidth > 0) return viewBoxWidth; |
| 45 | |
| 46 | const attributeWidth = parseNumberAttribute(svgElement.getAttribute("width")); |
| 47 | if (attributeWidth !== null && attributeWidth > 0) return attributeWidth; |
| 48 | |
| 49 | const rectWidth = svgElement.getBoundingClientRect().width; |
| 50 | return rectWidth > 0 ? rectWidth : null; |
| 51 | } |
| 52 | |
| 53 | function clampTextXPosition( |
| 54 | x: number, |
| 55 | width: number, |
| 56 | maxWidth: number, |
| 57 | ): number { |
| 58 | return Math.min(Math.max(x, 0), Math.max(maxWidth - width, 0)); |
| 59 | } |
| 60 | |
| 61 | function centerInfographicText(textElement: SVGGraphicsElement): void { |
| 62 | textElement.setAttribute("data-horizontal-align", CENTER_ALIGN); |
| 63 | |
| 64 | if (textElement instanceof SVGTextElement) { |
| 65 | textElement.setAttribute("text-anchor", "middle"); |
| 66 | } |
| 67 | |
| 68 | const textEntity = textElement.querySelector("span"); |
| 69 | if (textEntity instanceof HTMLSpanElement) { |
| 70 | textEntity.style.textAlign = "center"; |
| 71 | textEntity.style.justifyContent = "center"; |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | function getCenterAttributes( |
| 76 | textElement: SVGGraphicsElement, |
| 77 | ): Record<string, string | number> { |
| 78 | const attributes: Record<string, string | number> = { |
| 79 | "data-horizontal-align": CENTER_ALIGN, |
| 80 | }; |
| 81 | |
| 82 | if (textElement instanceof SVGTextElement) { |
| 83 | attributes["text-anchor"] = "middle"; |
| 84 | } |
| 85 | |
| 86 | return attributes; |
| 87 | } |
| 88 | |
| 89 | function getInfographicEditorState( |
| 90 | instance?: Infographic, |
| 91 | ): InfographicEditorState | undefined { |
| 92 | return (instance as unknown as InfographicEditorContainer | undefined)?.editor |
| 93 | ?.state; |
| 94 | } |
| 95 | |
| 96 | function widenInfographicText( |
| 97 | textElement: SVGGraphicsElement, |
| 98 | widthMultiplier: number, |
| 99 | state?: InfographicEditorState, |
| 100 | ): boolean { |
| 101 | const originalWidth = parseNumberAttribute(textElement.getAttribute("width")); |
| 102 | if (originalWidth === null || originalWidth <= 0) { |
| 103 | centerInfographicText(textElement); |
| 104 | state?.updateElement(textElement, { |
| 105 | attributes: getCenterAttributes(textElement), |
| 106 | }); |
| 107 | return true; |
| 108 | } |
| 109 | |
| 110 | const originalX = parseNumberAttribute(textElement.getAttribute("x")) ?? 0; |
| 111 | const viewportWidth = getSvgViewportWidth(textElement); |
| 112 | const nextWidth = |
| 113 | viewportWidth === null |
| 114 | ? originalWidth * widthMultiplier |
| 115 | : Math.min( |
| 116 | originalWidth * widthMultiplier, |
| 117 | viewportWidth * MAX_TEXT_VIEWPORT_RATIO, |
| 118 | ); |
| 119 | const centeredX = originalX - (nextWidth - originalWidth) / 2; |
| 120 | const nextX = |
| 121 | viewportWidth === null |
| 122 | ? centeredX |
| 123 | : clampTextXPosition(centeredX, nextWidth, viewportWidth); |
| 124 | |
| 125 | textElement.setAttribute("width", String(nextWidth)); |
| 126 | textElement.setAttribute("x", String(nextX)); |
| 127 | centerInfographicText(textElement); |
| 128 | |
| 129 | const attributes: Record<string, string | number> = { |
| 130 | ...getCenterAttributes(textElement), |
| 131 | width: nextWidth, |
| 132 | x: nextX, |
| 133 | }; |
| 134 | |
| 135 | state?.updateElement(textElement, { attributes }); |
| 136 | return true; |
| 137 | } |
| 138 | |
| 139 | export function applyInitialInfographicTitleLayout( |
| 140 | container: HTMLElement, |
| 141 | instance?: Infographic, |
| 142 | ): boolean { |
| 143 | const state = getInfographicEditorState(instance); |
| 144 | const titleElements = container.querySelectorAll<SVGGraphicsElement>( |
| 145 | '[data-element-type="title"]', |
| 146 | ); |
| 147 | const descElements = container.querySelectorAll<SVGGraphicsElement>( |
| 148 | '[data-element-type="desc"]', |
| 149 | ); |
| 150 | let didUpdate = false; |
| 151 | |
| 152 | for (const titleElement of titleElements) { |
| 153 | didUpdate = |
| 154 | widenInfographicText(titleElement, TITLE_WIDTH_MULTIPLIER, state) || |
| 155 | didUpdate; |
| 156 | } |
| 157 | |
| 158 | for (const descElement of descElements) { |
| 159 | didUpdate = |
| 160 | widenInfographicText(descElement, DESCRIPTION_WIDTH_MULTIPLIER, state) || |
| 161 | didUpdate; |
| 162 | } |
| 163 | |
| 164 | return didUpdate; |
| 165 | } |
| 166 |