@xivdyetools/svg 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +136 -0
  2. package/dist/accessibility-comparison.d.ts +66 -0
  3. package/dist/accessibility-comparison.d.ts.map +1 -0
  4. package/dist/accessibility-comparison.js +201 -0
  5. package/dist/accessibility-comparison.js.map +1 -0
  6. package/dist/base.d.ts +115 -0
  7. package/dist/base.d.ts.map +1 -0
  8. package/dist/base.js +201 -0
  9. package/dist/base.js.map +1 -0
  10. package/dist/budget-comparison.d.ts +121 -0
  11. package/dist/budget-comparison.d.ts.map +1 -0
  12. package/dist/budget-comparison.js +331 -0
  13. package/dist/budget-comparison.js.map +1 -0
  14. package/dist/comparison-grid.d.ts +61 -0
  15. package/dist/comparison-grid.d.ts.map +1 -0
  16. package/dist/comparison-grid.js +366 -0
  17. package/dist/comparison-grid.js.map +1 -0
  18. package/dist/contrast-matrix.d.ts +78 -0
  19. package/dist/contrast-matrix.d.ts.map +1 -0
  20. package/dist/contrast-matrix.js +303 -0
  21. package/dist/contrast-matrix.js.map +1 -0
  22. package/dist/dye-info-card.d.ts +34 -0
  23. package/dist/dye-info-card.d.ts.map +1 -0
  24. package/dist/dye-info-card.js +218 -0
  25. package/dist/dye-info-card.js.map +1 -0
  26. package/dist/gradient.d.ts +49 -0
  27. package/dist/gradient.d.ts.map +1 -0
  28. package/dist/gradient.js +131 -0
  29. package/dist/gradient.js.map +1 -0
  30. package/dist/harmony-wheel.d.ts +38 -0
  31. package/dist/harmony-wheel.d.ts.map +1 -0
  32. package/dist/harmony-wheel.js +158 -0
  33. package/dist/harmony-wheel.js.map +1 -0
  34. package/dist/index.d.ts +31 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +32 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/palette-grid.d.ts +105 -0
  39. package/dist/palette-grid.d.ts.map +1 -0
  40. package/dist/palette-grid.js +277 -0
  41. package/dist/palette-grid.js.map +1 -0
  42. package/dist/preset-swatch.d.ts +76 -0
  43. package/dist/preset-swatch.d.ts.map +1 -0
  44. package/dist/preset-swatch.js +226 -0
  45. package/dist/preset-swatch.js.map +1 -0
  46. package/dist/random-dyes-grid.d.ts +45 -0
  47. package/dist/random-dyes-grid.d.ts.map +1 -0
  48. package/dist/random-dyes-grid.js +143 -0
  49. package/dist/random-dyes-grid.js.map +1 -0
  50. package/package.json +56 -0
package/dist/base.js ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * SVG Base Utilities
3
+ *
4
+ * Core utilities for generating SVG graphics as strings.
5
+ * These SVGs are later converted to PNG using a platform-specific renderer
6
+ * (e.g., resvg-wasm for Cloudflare Workers, @resvg/resvg-js for Node.js).
7
+ */
8
+ /**
9
+ * XML-escapes a string for safe SVG inclusion
10
+ */
11
+ export function escapeXml(str) {
12
+ return str
13
+ .replace(/&/g, '&')
14
+ .replace(/</g, '&lt;')
15
+ .replace(/>/g, '&gt;')
16
+ .replace(/"/g, '&quot;')
17
+ .replace(/'/g, '&apos;');
18
+ }
19
+ /**
20
+ * Converts a hex color to RGB components
21
+ */
22
+ export function hexToRgb(hex) {
23
+ const cleanHex = hex.replace('#', '');
24
+ return {
25
+ r: parseInt(cleanHex.slice(0, 2), 16),
26
+ g: parseInt(cleanHex.slice(2, 4), 16),
27
+ b: parseInt(cleanHex.slice(4, 6), 16),
28
+ };
29
+ }
30
+ /**
31
+ * Converts RGB to hex color string
32
+ */
33
+ export function rgbToHex(r, g, b) {
34
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
35
+ }
36
+ /**
37
+ * Calculates the luminance of a color (for contrast calculations)
38
+ */
39
+ export function getLuminance(hex) {
40
+ const { r, g, b } = hexToRgb(hex);
41
+ // Relative luminance formula
42
+ const [rs, gs, bs] = [r, g, b].map((c) => {
43
+ const s = c / 255;
44
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
45
+ });
46
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
47
+ }
48
+ /**
49
+ * Determines if text should be light or dark based on background color
50
+ */
51
+ export function getContrastTextColor(bgHex) {
52
+ const luminance = getLuminance(bgHex);
53
+ return luminance > 0.179 ? '#000000' : '#ffffff';
54
+ }
55
+ /**
56
+ * Creates an SVG document wrapper
57
+ */
58
+ export function createSvgDocument(width, height, content) {
59
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
60
+ ${content}
61
+ </svg>`;
62
+ }
63
+ /**
64
+ * Creates a rectangle element
65
+ */
66
+ export function rect(x, y, width, height, fill, options = {}) {
67
+ const attrs = [
68
+ `x="${x}"`,
69
+ `y="${y}"`,
70
+ `width="${width}"`,
71
+ `height="${height}"`,
72
+ `fill="${fill}"`,
73
+ ];
74
+ if (options.rx)
75
+ attrs.push(`rx="${options.rx}"`);
76
+ if (options.ry)
77
+ attrs.push(`ry="${options.ry}"`);
78
+ if (options.stroke)
79
+ attrs.push(`stroke="${options.stroke}"`);
80
+ if (options.strokeWidth)
81
+ attrs.push(`stroke-width="${options.strokeWidth}"`);
82
+ if (options.opacity !== undefined)
83
+ attrs.push(`opacity="${options.opacity}"`);
84
+ return `<rect ${attrs.join(' ')}/>`;
85
+ }
86
+ /**
87
+ * Creates a circle element
88
+ */
89
+ export function circle(cx, cy, r, fill, options = {}) {
90
+ const attrs = [
91
+ `cx="${cx}"`,
92
+ `cy="${cy}"`,
93
+ `r="${r}"`,
94
+ `fill="${fill}"`,
95
+ ];
96
+ if (options.stroke)
97
+ attrs.push(`stroke="${options.stroke}"`);
98
+ if (options.strokeWidth)
99
+ attrs.push(`stroke-width="${options.strokeWidth}"`);
100
+ if (options.opacity !== undefined)
101
+ attrs.push(`opacity="${options.opacity}"`);
102
+ return `<circle ${attrs.join(' ')}/>`;
103
+ }
104
+ /**
105
+ * Creates a line element
106
+ */
107
+ export function line(x1, y1, x2, y2, stroke, strokeWidth = 1, options = {}) {
108
+ const attrs = [
109
+ `x1="${x1}"`,
110
+ `y1="${y1}"`,
111
+ `x2="${x2}"`,
112
+ `y2="${y2}"`,
113
+ `stroke="${stroke}"`,
114
+ `stroke-width="${strokeWidth}"`,
115
+ ];
116
+ if (options.opacity !== undefined)
117
+ attrs.push(`opacity="${options.opacity}"`);
118
+ if (options.dashArray)
119
+ attrs.push(`stroke-dasharray="${options.dashArray}"`);
120
+ return `<line ${attrs.join(' ')}/>`;
121
+ }
122
+ /**
123
+ * Creates a text element
124
+ */
125
+ export function text(x, y, content, options = {}) {
126
+ const attrs = [
127
+ `x="${x}"`,
128
+ `y="${y}"`,
129
+ ];
130
+ if (options.fill)
131
+ attrs.push(`fill="${options.fill}"`);
132
+ if (options.fontSize)
133
+ attrs.push(`font-size="${options.fontSize}"`);
134
+ if (options.fontFamily)
135
+ attrs.push(`font-family="${options.fontFamily}"`);
136
+ if (options.fontWeight)
137
+ attrs.push(`font-weight="${options.fontWeight}"`);
138
+ if (options.textAnchor)
139
+ attrs.push(`text-anchor="${options.textAnchor}"`);
140
+ if (options.dominantBaseline)
141
+ attrs.push(`dominant-baseline="${options.dominantBaseline}"`);
142
+ return `<text ${attrs.join(' ')}>${escapeXml(content)}</text>`;
143
+ }
144
+ /**
145
+ * Creates an arc path for pie/donut charts
146
+ */
147
+ export function arcPath(cx, cy, radius, startAngle, endAngle) {
148
+ const startRad = (startAngle - 90) * (Math.PI / 180);
149
+ const endRad = (endAngle - 90) * (Math.PI / 180);
150
+ const x1 = cx + radius * Math.cos(startRad);
151
+ const y1 = cy + radius * Math.sin(startRad);
152
+ const x2 = cx + radius * Math.cos(endRad);
153
+ const y2 = cy + radius * Math.sin(endRad);
154
+ const largeArc = endAngle - startAngle > 180 ? 1 : 0;
155
+ return `M ${cx} ${cy} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z`;
156
+ }
157
+ /**
158
+ * Creates a group element
159
+ */
160
+ export function group(content, transform) {
161
+ if (transform) {
162
+ return `<g transform="${transform}">${content}</g>`;
163
+ }
164
+ return `<g>${content}</g>`;
165
+ }
166
+ /**
167
+ * Theme colors for consistent styling
168
+ */
169
+ export const THEME = {
170
+ background: '#1a1a2e',
171
+ backgroundLight: '#2d2d3d',
172
+ text: '#ffffff',
173
+ textMuted: '#909090',
174
+ textDim: '#666666',
175
+ accent: '#5865f2', // Discord Blurple
176
+ border: '#404050',
177
+ success: '#57f287',
178
+ warning: '#fee75c',
179
+ error: '#ed4245',
180
+ };
181
+ /**
182
+ * Font families for consistent typography.
183
+ * These names match the bundled font files loaded by the renderer.
184
+ *
185
+ * - header: Space Grotesk (variable 300-700) - titles, headers
186
+ * - primary: Onest (variable 100-900) - body text, labels
187
+ * - mono: Habibi (regular only) - hex codes, monospace-like text
188
+ * - cjk: Noto Sans SC + Noto Sans KR - Japanese, Korean, Chinese text
189
+ * - primaryCjk: Onest with CJK/KR fallback - for localized text that may contain CJK
190
+ */
191
+ export const FONTS = {
192
+ header: 'Space Grotesk',
193
+ primary: 'Onest',
194
+ mono: 'Habibi',
195
+ cjk: 'Noto Sans SC, Noto Sans KR',
196
+ /** Use this for headings that may contain CJK characters (e.g., dye names) */
197
+ headerCjk: 'Space Grotesk, Noto Sans SC, Noto Sans KR',
198
+ /** Use this for body text that may contain CJK characters (e.g., dye names) */
199
+ primaryCjk: 'Onest, Noto Sans SC, Noto Sans KR',
200
+ };
201
+ //# sourceMappingURL=base.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"base.js","sourceRoot":"","sources":["../src/base.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;GAEG;AACH,MAAM,UAAU,SAAS,CAAC,GAAW;IACnC,OAAO,GAAG;SACP,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC;SACvB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;AAC7B,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,QAAQ,CAAC,GAAW;IAClC,MAAM,QAAQ,GAAG,GAAG,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;IACtC,OAAO;QACL,CAAC,EAAE,QAAQ,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC;QACrC,CAAC,EAAE,QAAQ,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC;QACrC,CAAC,EAAE,QAAQ,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC;KACtC,CAAC;AACJ,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,QAAQ,CAAC,CAAS,EAAE,CAAS,EAAE,CAAS;IACtD,OAAO,IAAI,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC;AACnH,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,YAAY,CAAC,GAAW;IACtC,MAAM,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC;IAClC,6BAA6B;IAC7B,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;QACvC,MAAM,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC;QAClB,OAAO,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,GAAG,KAAK,EAAE,GAAG,CAAC,CAAC;IACvE,CAAC,CAAC,CAAC;IACH,OAAO,MAAM,GAAG,EAAE,GAAG,MAAM,GAAG,EAAE,GAAG,MAAM,GAAG,EAAE,CAAC;AACjD,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,oBAAoB,CAAC,KAAa;IAChD,MAAM,SAAS,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC;IACtC,OAAO,SAAS,GAAG,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC;AACnD,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,iBAAiB,CAC/B,KAAa,EACb,MAAc,EACd,OAAe;IAEf,OAAO,kDAAkD,KAAK,aAAa,MAAM,kBAAkB,KAAK,IAAI,MAAM;EAClH,OAAO;OACF,CAAC;AACR,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,IAAI,CAClB,CAAS,EACT,CAAS,EACT,KAAa,EACb,MAAc,EACd,IAAY,EACZ,UAMI,EAAE;IAEN,MAAM,KAAK,GAAG;QACZ,MAAM,CAAC,GAAG;QACV,MAAM,CAAC,GAAG;QACV,UAAU,KAAK,GAAG;QAClB,WAAW,MAAM,GAAG;QACpB,SAAS,IAAI,GAAG;KACjB,CAAC;IAEF,IAAI,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,IAAI,CAAC,OAAO,OAAO,CAAC,EAAE,GAAG,CAAC,CAAC;IACjD,IAAI,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,IAAI,CAAC,OAAO,OAAO,CAAC,EAAE,GAAG,CAAC,CAAC;IACjD,IAAI,OAAO,CAAC,MAAM;QAAE,KAAK,CAAC,IAAI,CAAC,WAAW,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC;IAC7D,IAAI,OAAO,CAAC,WAAW;QAAE,KAAK,CAAC,IAAI,CAAC,iBAAiB,OAAO,CAAC,WAAW,GAAG,CAAC,CAAC;IAC7E,IAAI,OAAO,CAAC,OAAO,KAAK,SAAS;QAAE,KAAK,CAAC,IAAI,CAAC,YAAY,OAAO,CAAC,OAAO,GAAG,CAAC,CAAC;IAE9E,OAAO,SAAS,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC;AACtC,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,MAAM,CACpB,EAAU,EACV,EAAU,EACV,CAAS,EACT,IAAY,EACZ,UAII,EAAE;IAEN,MAAM,KAAK,GAAG;QACZ,OAAO,EAAE,GAAG;QACZ,OAAO,EAAE,GAAG;QACZ,MAAM,CAAC,GAAG;QACV,SAAS,IAAI,GAAG;KACjB,CAAC;IAEF,IAAI,OAAO,CAAC,MAAM;QAAE,KAAK,CAAC,IAAI,CAAC,WAAW,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC;IAC7D,IAAI,OAAO,CAAC,WAAW;QAAE,KAAK,CAAC,IAAI,CAAC,iBAAiB,OAAO,CAAC,WAAW,GAAG,CAAC,CAAC;IAC7E,IAAI,OAAO,CAAC,OAAO,KAAK,SAAS;QAAE,KAAK,CAAC,IAAI,CAAC,YAAY,OAAO,CAAC,OAAO,GAAG,CAAC,CAAC;IAE9E,OAAO,WAAW,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC;AACxC,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,IAAI,CAClB,EAAU,EACV,EAAU,EACV,EAAU,EACV,EAAU,EACV,MAAc,EACd,cAAsB,CAAC,EACvB,UAGI,EAAE;IAEN,MAAM,KAAK,GAAG;QACZ,OAAO,EAAE,GAAG;QACZ,OAAO,EAAE,GAAG;QACZ,OAAO,EAAE,GAAG;QACZ,OAAO,EAAE,GAAG;QACZ,WAAW,MAAM,GAAG;QACpB,iBAAiB,WAAW,GAAG;KAChC,CAAC;IAEF,IAAI,OAAO,CAAC,OAAO,KAAK,SAAS;QAAE,KAAK,CAAC,IAAI,CAAC,YAAY,OAAO,CAAC,OAAO,GAAG,CAAC,CAAC;IAC9E,IAAI,OAAO,CAAC,SAAS;QAAE,KAAK,CAAC,IAAI,CAAC,qBAAqB,OAAO,CAAC,SAAS,GAAG,CAAC,CAAC;IAE7E,OAAO,SAAS,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC;AACtC,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,IAAI,CAClB,CAAS,EACT,CAAS,EACT,OAAe,EACf,UAOI,EAAE;IAEN,MAAM,KAAK,GAAG;QACZ,MAAM,CAAC,GAAG;QACV,MAAM,CAAC,GAAG;KACX,CAAC;IAEF,IAAI,OAAO,CAAC,IAAI;QAAE,KAAK,CAAC,IAAI,CAAC,SAAS,OAAO,CAAC,IAAI,GAAG,CAAC,CAAC;IACvD,IAAI,OAAO,CAAC,QAAQ;QAAE,KAAK,CAAC,IAAI,CAAC,cAAc,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;IACpE,IAAI,OAAO,CAAC,UAAU;QAAE,KAAK,CAAC,IAAI,CAAC,gBAAgB,OAAO,CAAC,UAAU,GAAG,CAAC,CAAC;IAC1E,IAAI,OAAO,CAAC,UAAU;QAAE,KAAK,CAAC,IAAI,CAAC,gBAAgB,OAAO,CAAC,UAAU,GAAG,CAAC,CAAC;IAC1E,IAAI,OAAO,CAAC,UAAU;QAAE,KAAK,CAAC,IAAI,CAAC,gBAAgB,OAAO,CAAC,UAAU,GAAG,CAAC,CAAC;IAC1E,IAAI,OAAO,CAAC,gBAAgB;QAAE,KAAK,CAAC,IAAI,CAAC,sBAAsB,OAAO,CAAC,gBAAgB,GAAG,CAAC,CAAC;IAE5F,OAAO,SAAS,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,SAAS,CAAC,OAAO,CAAC,SAAS,CAAC;AACjE,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,OAAO,CACrB,EAAU,EACV,EAAU,EACV,MAAc,EACd,UAAkB,EAClB,QAAgB;IAEhB,MAAM,QAAQ,GAAG,CAAC,UAAU,GAAG,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,GAAG,GAAG,CAAC,CAAC;IACrD,MAAM,MAAM,GAAG,CAAC,QAAQ,GAAG,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,GAAG,GAAG,CAAC,CAAC;IAEjD,MAAM,EAAE,GAAG,EAAE,GAAG,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IAC5C,MAAM,EAAE,GAAG,EAAE,GAAG,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IAC5C,MAAM,EAAE,GAAG,EAAE,GAAG,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IAC1C,MAAM,EAAE,GAAG,EAAE,GAAG,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IAE1C,MAAM,QAAQ,GAAG,QAAQ,GAAG,UAAU,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAErD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,MAAM,IAAI,MAAM,MAAM,QAAQ,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC;AAC3F,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,KAAK,CAAC,OAAe,EAAE,SAAkB;IACvD,IAAI,SAAS,EAAE,CAAC;QACd,OAAO,iBAAiB,SAAS,KAAK,OAAO,MAAM,CAAC;IACtD,CAAC;IACD,OAAO,MAAM,OAAO,MAAM,CAAC;AAC7B,CAAC;AAED;;GAEG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG;IACnB,UAAU,EAAE,SAAS;IACrB,eAAe,EAAE,SAAS;IAC1B,IAAI,EAAE,SAAS;IACf,SAAS,EAAE,SAAS;IACpB,OAAO,EAAE,SAAS;IAClB,MAAM,EAAE,SAAS,EAAE,kBAAkB;IACrC,MAAM,EAAE,SAAS;IACjB,OAAO,EAAE,SAAS;IAClB,OAAO,EAAE,SAAS;IAClB,KAAK,EAAE,SAAS;CACR,CAAC;AAEX;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG;IACnB,MAAM,EAAE,eAAe;IACvB,OAAO,EAAE,OAAO;IAChB,IAAI,EAAE,QAAQ;IACd,GAAG,EAAE,4BAA4B;IACjC,8EAA8E;IAC9E,SAAS,EAAE,2CAA2C;IACtD,+EAA+E;IAC/E,UAAU,EAAE,mCAAmC;CACvC,CAAC"}
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Budget Comparison SVG Generator
3
+ *
4
+ * Generates a visual comparison of target dye vs budget alternatives.
5
+ * Used by the /budget command to display results.
6
+ *
7
+ * Layout:
8
+ * +----------------------------------------------------------+
9
+ * | BUDGET ALTERNATIVES FOR |
10
+ * | [Target Swatch] Pure White Target Price: 85,000 Gil |
11
+ * +----------------------------------------------------------+
12
+ * | [Alt Swatch] Snow White Price: 5,000 Save: 94% |
13
+ * | #E4DFD0 Distance: 12 (Excellent) |
14
+ * +----------------------------------------------------------+
15
+ * | [Alt Swatch] Ash Grey Price: 2,500 Save: 97% |
16
+ * | #8B8B8B Distance: 28 (Good) |
17
+ * +----------------------------------------------------------+
18
+ *
19
+ * @module svg/budget-comparison
20
+ */
21
+ import type { Dye } from '@xivdyetools/core';
22
+ /**
23
+ * Price data for a dye on the market board.
24
+ * This interface is structurally compatible with the discord-worker's
25
+ * richer DyePriceData type — only the fields needed for SVG rendering
26
+ * are declared here.
27
+ */
28
+ export interface DyePriceData {
29
+ currentMinPrice: number;
30
+ world: string;
31
+ listingCount: number;
32
+ }
33
+ /**
34
+ * A budget alternative suggestion.
35
+ */
36
+ export interface BudgetSuggestion {
37
+ dye: Dye;
38
+ price: DyePriceData | null;
39
+ colorDistance: number;
40
+ savings: number;
41
+ savingsPercent: number;
42
+ valueScore: number;
43
+ }
44
+ /**
45
+ * Sort option for budget results.
46
+ */
47
+ export type BudgetSortOption = 'price' | 'color_match' | 'value_score';
48
+ /**
49
+ * Format a Gil amount with locale-appropriate separators.
50
+ */
51
+ export declare function formatGil(amount: number): string;
52
+ /**
53
+ * Translated labels for the budget comparison SVG
54
+ *
55
+ * Pre-resolved strings are ready to render as-is.
56
+ * Template strings use {var} placeholders for per-row interpolation.
57
+ */
58
+ export interface BudgetSvgLabels {
59
+ /** Header title (e.g., "BUDGET ALTERNATIVES FOR") */
60
+ headerLabel: string;
61
+ /** Label above target price (e.g., "Target Price") */
62
+ targetPriceLabel: string;
63
+ /** No listings available (e.g., "No listings") */
64
+ noListings: string;
65
+ /** No alternatives found (e.g., "No cheaper alternatives found") */
66
+ noAlternatives: string;
67
+ /** Pre-resolved sort indicator (e.g., "Sorted by: Best Value") */
68
+ sortedBy: string;
69
+ /** Pre-resolved world subtitle (e.g., "on Aether") */
70
+ onWorld: string;
71
+ /** Template: "{amount} Gil" */
72
+ gilAmountTemplate: string;
73
+ /** Template: "Save {amount} ({percent}%)" */
74
+ saveAmountTemplate: string;
75
+ /** Template: "{count} listings" */
76
+ listingCountTemplate: string;
77
+ /** Translated distance quality labels */
78
+ distanceQuality: {
79
+ perfect: string;
80
+ excellent: string;
81
+ good: string;
82
+ fair: string;
83
+ approximate: string;
84
+ };
85
+ /** Localized dye names keyed by itemID (falls back to Dye.name if missing) */
86
+ dyeNames: Record<number, string>;
87
+ /** Localized category names keyed by English category (falls back to raw category if missing) */
88
+ categoryNames: Record<string, string>;
89
+ }
90
+ /**
91
+ * Options for generating the budget comparison SVG
92
+ */
93
+ export interface BudgetComparisonOptions {
94
+ /** The target dye (expensive one) */
95
+ targetDye: Dye;
96
+ /** Target dye's price (null if no listings) */
97
+ targetPrice: DyePriceData | null;
98
+ /** Alternative dye suggestions */
99
+ alternatives: BudgetSuggestion[];
100
+ /** World/datacenter used for prices */
101
+ world: string;
102
+ /** How results are sorted */
103
+ sortBy: BudgetSortOption;
104
+ /** Translated labels for all text in the SVG */
105
+ labels: BudgetSvgLabels;
106
+ /** Canvas width in pixels (default: 800) */
107
+ width?: number;
108
+ }
109
+ /**
110
+ * Generate a budget comparison SVG showing target vs alternatives
111
+ */
112
+ export declare function generateBudgetComparison(options: BudgetComparisonOptions): string;
113
+ /**
114
+ * Generate an empty state SVG when no world is set
115
+ */
116
+ export declare function generateNoWorldSetSvg(width?: number): string;
117
+ /**
118
+ * Generate an error state SVG
119
+ */
120
+ export declare function generateErrorSvg(message: string, width?: number): string;
121
+ //# sourceMappingURL=budget-comparison.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"budget-comparison.d.ts","sourceRoot":"","sources":["../src/budget-comparison.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,mBAAmB,CAAC;AAgB7C;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,eAAe,EAAE,MAAM,CAAC;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,GAAG,CAAC;IACT,KAAK,EAAE,YAAY,GAAG,IAAI,CAAC;IAC3B,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,MAAM,gBAAgB,GAAG,OAAO,GAAG,aAAa,GAAG,aAAa,CAAC;AAEvE;;GAEG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAEhD;AAMD;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,qDAAqD;IACrD,WAAW,EAAE,MAAM,CAAC;IACpB,sDAAsD;IACtD,gBAAgB,EAAE,MAAM,CAAC;IACzB,kDAAkD;IAClD,UAAU,EAAE,MAAM,CAAC;IACnB,oEAAoE;IACpE,cAAc,EAAE,MAAM,CAAC;IACvB,kEAAkE;IAClE,QAAQ,EAAE,MAAM,CAAC;IACjB,sDAAsD;IACtD,OAAO,EAAE,MAAM,CAAC;IAChB,+BAA+B;IAC/B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,6CAA6C;IAC7C,kBAAkB,EAAE,MAAM,CAAC;IAC3B,mCAAmC;IACnC,oBAAoB,EAAE,MAAM,CAAC;IAC7B,yCAAyC;IACzC,eAAe,EAAE;QACf,OAAO,EAAE,MAAM,CAAC;QAChB,SAAS,EAAE,MAAM,CAAC;QAClB,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,8EAA8E;IAC9E,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,iGAAiG;IACjG,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACvC;AAED;;GAEG;AACH,MAAM,WAAW,uBAAuB;IACtC,qCAAqC;IACrC,SAAS,EAAE,GAAG,CAAC;IACf,+CAA+C;IAC/C,WAAW,EAAE,YAAY,GAAG,IAAI,CAAC;IACjC,kCAAkC;IAClC,YAAY,EAAE,gBAAgB,EAAE,CAAC;IACjC,uCAAuC;IACvC,KAAK,EAAE,MAAM,CAAC;IACd,6BAA6B;IAC7B,MAAM,EAAE,gBAAgB,CAAC;IACzB,gDAAgD;IAChD,MAAM,EAAE,eAAe,CAAC;IACxB,4CAA4C;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAyCD;;GAEG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,uBAAuB,GAAG,MAAM,CA4DjF;AA0PD;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,GAAE,MAAsB,GAAG,MAAM,CAmC3E;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,GAAE,MAAsB,GAAG,MAAM,CA0BvF"}
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Budget Comparison SVG Generator
3
+ *
4
+ * Generates a visual comparison of target dye vs budget alternatives.
5
+ * Used by the /budget command to display results.
6
+ *
7
+ * Layout:
8
+ * +----------------------------------------------------------+
9
+ * | BUDGET ALTERNATIVES FOR |
10
+ * | [Target Swatch] Pure White Target Price: 85,000 Gil |
11
+ * +----------------------------------------------------------+
12
+ * | [Alt Swatch] Snow White Price: 5,000 Save: 94% |
13
+ * | #E4DFD0 Distance: 12 (Excellent) |
14
+ * +----------------------------------------------------------+
15
+ * | [Alt Swatch] Ash Grey Price: 2,500 Save: 97% |
16
+ * | #8B8B8B Distance: 28 (Good) |
17
+ * +----------------------------------------------------------+
18
+ *
19
+ * @module svg/budget-comparison
20
+ */
21
+ import { createSvgDocument, rect, text, line, THEME, FONTS, escapeXml, getContrastTextColor, } from './base.js';
22
+ /**
23
+ * Format a Gil amount with locale-appropriate separators.
24
+ */
25
+ export function formatGil(amount) {
26
+ return amount.toLocaleString('en-US');
27
+ }
28
+ // ============================================================================
29
+ // Constants
30
+ // ============================================================================
31
+ const DEFAULT_WIDTH = 800;
32
+ const PADDING = 24;
33
+ const HEADER_HEIGHT = 120;
34
+ const ROW_HEIGHT = 90;
35
+ const SWATCH_SIZE = 56;
36
+ const TARGET_SWATCH_SIZE = 72;
37
+ // ============================================================================
38
+ // Helpers
39
+ // ============================================================================
40
+ /**
41
+ * Fill a template string with variable values
42
+ *
43
+ * @example fillTemplate("{amount} Gil", { amount: "5,000" }) → "5,000 Gil"
44
+ */
45
+ function fillTemplate(template, vars) {
46
+ return template.replace(/\{(\w+)\}/g, (_, key) => String(vars[key] ?? ''));
47
+ }
48
+ /**
49
+ * Map a color distance value to a quality tier key
50
+ */
51
+ function getDistanceQualityKey(distance) {
52
+ if (distance === 0)
53
+ return 'perfect';
54
+ if (distance < 10)
55
+ return 'excellent';
56
+ if (distance < 25)
57
+ return 'good';
58
+ if (distance < 50)
59
+ return 'fair';
60
+ return 'approximate';
61
+ }
62
+ // ============================================================================
63
+ // SVG Generation
64
+ // ============================================================================
65
+ /**
66
+ * Generate a budget comparison SVG showing target vs alternatives
67
+ */
68
+ export function generateBudgetComparison(options) {
69
+ const { targetDye, targetPrice, alternatives, labels, width = DEFAULT_WIDTH, } = options;
70
+ // Calculate dimensions
71
+ const hasAlternatives = alternatives.length > 0;
72
+ const contentHeight = hasAlternatives ? alternatives.length * ROW_HEIGHT : 60;
73
+ const height = PADDING * 2 + HEADER_HEIGHT + contentHeight;
74
+ const elements = [];
75
+ // Background
76
+ elements.push(rect(0, 0, width, height, THEME.background, { rx: 12, ry: 12 }));
77
+ // Header section
78
+ elements.push(generateHeader(targetDye, targetPrice, labels, width));
79
+ // Separator after header
80
+ elements.push(line(PADDING, HEADER_HEIGHT, width - PADDING, HEADER_HEIGHT, THEME.border, 2));
81
+ // Alternatives section
82
+ if (hasAlternatives) {
83
+ alternatives.forEach((alt, index) => {
84
+ const rowY = HEADER_HEIGHT + index * ROW_HEIGHT;
85
+ elements.push(generateAlternativeRow(alt, PADDING, rowY, width - PADDING * 2, targetPrice, labels));
86
+ // Separator line (except after last row)
87
+ if (index < alternatives.length - 1) {
88
+ elements.push(line(PADDING + 20, rowY + ROW_HEIGHT, width - PADDING - 20, rowY + ROW_HEIGHT, THEME.border, 1));
89
+ }
90
+ });
91
+ }
92
+ else {
93
+ // No alternatives found message
94
+ elements.push(text(width / 2, HEADER_HEIGHT + 30, labels.noAlternatives, {
95
+ fill: THEME.textMuted,
96
+ fontSize: 16,
97
+ fontFamily: FONTS.primaryCjk,
98
+ textAnchor: 'middle',
99
+ }));
100
+ }
101
+ return createSvgDocument(width, height, elements.join('\n'));
102
+ }
103
+ /**
104
+ * Generate the header section with target dye info
105
+ */
106
+ function generateHeader(targetDye, targetPrice, labels, width) {
107
+ const elements = [];
108
+ // Title
109
+ elements.push(text(PADDING, 35, labels.headerLabel, {
110
+ fill: THEME.textMuted,
111
+ fontSize: 12,
112
+ fontFamily: FONTS.primaryCjk,
113
+ fontWeight: 500,
114
+ }));
115
+ // Target dye swatch
116
+ const swatchY = 50;
117
+ elements.push(rect(PADDING, swatchY, TARGET_SWATCH_SIZE, TARGET_SWATCH_SIZE, targetDye.hex, {
118
+ rx: 8,
119
+ ry: 8,
120
+ stroke: THEME.accent,
121
+ strokeWidth: 3,
122
+ }));
123
+ // Hex value on swatch
124
+ const swatchTextColor = getContrastTextColor(targetDye.hex);
125
+ elements.push(text(PADDING + TARGET_SWATCH_SIZE / 2, swatchY + TARGET_SWATCH_SIZE - 8, targetDye.hex.toUpperCase(), {
126
+ fill: swatchTextColor,
127
+ fontSize: 10,
128
+ fontFamily: FONTS.mono,
129
+ textAnchor: 'middle',
130
+ }));
131
+ // Target dye name (localized)
132
+ const infoX = PADDING + TARGET_SWATCH_SIZE + 16;
133
+ const targetName = labels.dyeNames[targetDye.itemID] ?? targetDye.name;
134
+ elements.push(text(infoX, 70, escapeXml(targetName), {
135
+ fill: THEME.text,
136
+ fontSize: 24,
137
+ fontFamily: FONTS.headerCjk,
138
+ fontWeight: 600,
139
+ }));
140
+ // Category (localized)
141
+ const targetCategory = labels.categoryNames[targetDye.category] ?? targetDye.category;
142
+ elements.push(text(infoX, 92, escapeXml(targetCategory), {
143
+ fill: THEME.textMuted,
144
+ fontSize: 14,
145
+ fontFamily: FONTS.primaryCjk,
146
+ }));
147
+ // Price section (right side)
148
+ const priceX = width - PADDING;
149
+ if (targetPrice) {
150
+ elements.push(text(priceX, 55, labels.targetPriceLabel, {
151
+ fill: THEME.textMuted,
152
+ fontSize: 10,
153
+ fontFamily: FONTS.primaryCjk,
154
+ fontWeight: 500,
155
+ textAnchor: 'end',
156
+ }));
157
+ elements.push(text(priceX, 80, fillTemplate(labels.gilAmountTemplate, { amount: formatGil(targetPrice.currentMinPrice) }), {
158
+ fill: THEME.warning,
159
+ fontSize: 22,
160
+ fontFamily: FONTS.header,
161
+ fontWeight: 600,
162
+ textAnchor: 'end',
163
+ }));
164
+ elements.push(text(priceX, 100, labels.onWorld, {
165
+ fill: THEME.textDim,
166
+ fontSize: 12,
167
+ fontFamily: FONTS.primaryCjk,
168
+ textAnchor: 'end',
169
+ }));
170
+ }
171
+ else {
172
+ elements.push(text(priceX, 70, labels.noListings, {
173
+ fill: THEME.textMuted,
174
+ fontSize: 16,
175
+ fontFamily: FONTS.primaryCjk,
176
+ textAnchor: 'end',
177
+ }));
178
+ elements.push(text(priceX, 92, labels.onWorld, {
179
+ fill: THEME.textDim,
180
+ fontSize: 12,
181
+ fontFamily: FONTS.primaryCjk,
182
+ textAnchor: 'end',
183
+ }));
184
+ }
185
+ // Sort indicator
186
+ elements.push(text(width / 2, HEADER_HEIGHT - 8, labels.sortedBy, {
187
+ fill: THEME.textDim,
188
+ fontSize: 11,
189
+ fontFamily: FONTS.primaryCjk,
190
+ textAnchor: 'middle',
191
+ }));
192
+ return elements.join('\n');
193
+ }
194
+ /**
195
+ * Generate a row for an alternative dye
196
+ */
197
+ function generateAlternativeRow(alt, x, y, width, targetPrice, labels) {
198
+ const elements = [];
199
+ const rowPadding = 12;
200
+ // Row background
201
+ elements.push(rect(x, y + 6, width, ROW_HEIGHT - 12, THEME.backgroundLight, {
202
+ rx: 8,
203
+ ry: 8,
204
+ opacity: 0.5,
205
+ }));
206
+ // Dye swatch
207
+ const swatchX = x + rowPadding;
208
+ const swatchY = y + (ROW_HEIGHT - SWATCH_SIZE) / 2;
209
+ elements.push(rect(swatchX, swatchY, SWATCH_SIZE, SWATCH_SIZE, alt.dye.hex, {
210
+ rx: 6,
211
+ ry: 6,
212
+ stroke: THEME.border,
213
+ strokeWidth: 2,
214
+ }));
215
+ // Dye name (localized) and hex
216
+ const infoX = swatchX + SWATCH_SIZE + 14;
217
+ const altName = labels.dyeNames[alt.dye.itemID] ?? alt.dye.name;
218
+ elements.push(text(infoX, y + 35, escapeXml(altName), {
219
+ fill: THEME.text,
220
+ fontSize: 16,
221
+ fontFamily: FONTS.primaryCjk,
222
+ fontWeight: 600,
223
+ }));
224
+ elements.push(text(infoX, y + 55, alt.dye.hex.toUpperCase(), {
225
+ fill: THEME.textDim,
226
+ fontSize: 12,
227
+ fontFamily: FONTS.mono,
228
+ }));
229
+ // Color distance badge (no emoji/Δ — fonts lack those glyphs in resvg)
230
+ const qualityKey = getDistanceQualityKey(alt.colorDistance);
231
+ const qualityLabel = labels.distanceQuality[qualityKey];
232
+ const distanceText = `${qualityLabel} (dE ${alt.colorDistance.toFixed(1)})`;
233
+ elements.push(text(infoX, y + 75, distanceText, {
234
+ fill: THEME.textMuted,
235
+ fontSize: 11,
236
+ fontFamily: FONTS.primaryCjk,
237
+ }));
238
+ // Price section (right side)
239
+ const priceX = x + width - rowPadding;
240
+ if (alt.price) {
241
+ // Price
242
+ elements.push(text(priceX, y + 35, fillTemplate(labels.gilAmountTemplate, { amount: formatGil(alt.price.currentMinPrice) }), {
243
+ fill: THEME.success,
244
+ fontSize: 18,
245
+ fontFamily: FONTS.header,
246
+ fontWeight: 600,
247
+ textAnchor: 'end',
248
+ }));
249
+ // Savings
250
+ if (targetPrice && alt.savings > 0) {
251
+ const savingsText = fillTemplate(labels.saveAmountTemplate, {
252
+ amount: formatGil(alt.savings),
253
+ percent: alt.savingsPercent.toFixed(0),
254
+ });
255
+ elements.push(text(priceX, y + 55, savingsText, {
256
+ fill: THEME.accent,
257
+ fontSize: 13,
258
+ fontFamily: FONTS.primaryCjk,
259
+ fontWeight: 500,
260
+ textAnchor: 'end',
261
+ }));
262
+ }
263
+ // Listings count
264
+ elements.push(text(priceX, y + 75, fillTemplate(labels.listingCountTemplate, { count: alt.price.listingCount }), {
265
+ fill: THEME.textDim,
266
+ fontSize: 11,
267
+ fontFamily: FONTS.primaryCjk,
268
+ textAnchor: 'end',
269
+ }));
270
+ }
271
+ else {
272
+ elements.push(text(priceX, y + 50, labels.noListings, {
273
+ fill: THEME.textMuted,
274
+ fontSize: 14,
275
+ fontFamily: FONTS.primaryCjk,
276
+ textAnchor: 'end',
277
+ }));
278
+ }
279
+ return elements.join('\n');
280
+ }
281
+ /**
282
+ * Generate an empty state SVG when no world is set
283
+ */
284
+ export function generateNoWorldSetSvg(width = DEFAULT_WIDTH) {
285
+ const height = 160;
286
+ const elements = [];
287
+ elements.push(rect(0, 0, width, height, THEME.background, { rx: 12, ry: 12 }));
288
+ elements.push(text(width / 2, 60, 'No World Set', {
289
+ fill: THEME.warning,
290
+ fontSize: 24,
291
+ fontFamily: FONTS.header,
292
+ fontWeight: 600,
293
+ textAnchor: 'middle',
294
+ }));
295
+ elements.push(text(width / 2, 95, 'Use /budget set_world to set your preferred', {
296
+ fill: THEME.textMuted,
297
+ fontSize: 14,
298
+ fontFamily: FONTS.primary,
299
+ textAnchor: 'middle',
300
+ }));
301
+ elements.push(text(width / 2, 115, 'world or datacenter for price lookups.', {
302
+ fill: THEME.textMuted,
303
+ fontSize: 14,
304
+ fontFamily: FONTS.primary,
305
+ textAnchor: 'middle',
306
+ }));
307
+ return createSvgDocument(width, height, elements.join('\n'));
308
+ }
309
+ /**
310
+ * Generate an error state SVG
311
+ */
312
+ export function generateErrorSvg(message, width = DEFAULT_WIDTH) {
313
+ const height = 120;
314
+ const elements = [];
315
+ elements.push(rect(0, 0, width, height, THEME.background, { rx: 12, ry: 12 }));
316
+ elements.push(text(width / 2, 50, 'Error', {
317
+ fill: THEME.error,
318
+ fontSize: 20,
319
+ fontFamily: FONTS.header,
320
+ fontWeight: 600,
321
+ textAnchor: 'middle',
322
+ }));
323
+ elements.push(text(width / 2, 80, escapeXml(message), {
324
+ fill: THEME.textMuted,
325
+ fontSize: 14,
326
+ fontFamily: FONTS.primary,
327
+ textAnchor: 'middle',
328
+ }));
329
+ return createSvgDocument(width, height, elements.join('\n'));
330
+ }
331
+ //# sourceMappingURL=budget-comparison.js.map