@mstone6969/graph 0.1.0 → 0.2.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 (238) hide show
  1. package/README.md +111 -14
  2. package/dist/ExcelWriter-3ZSYUH4T.cjs +154 -0
  3. package/dist/GraphType.js +24 -38
  4. package/dist/PdfWriter-6HJFYMCO.cjs +205 -0
  5. package/dist/catalog-YWX6FHRM.cjs +8 -0
  6. package/dist/catalog.js +5 -1
  7. package/dist/charts/bars/index.js +2 -12
  8. package/dist/charts/comparison/EffectForestPlot.js +1 -1
  9. package/dist/charts/comparison/index.js +1 -8
  10. package/dist/charts/demography/Nomogram.js +3 -3
  11. package/dist/charts/density/HexbinChart.js +6 -4
  12. package/dist/charts/diagrams/ArchitectureCanvas.js +68 -0
  13. package/dist/charts/diagrams/ArchitectureDiagram.js +331 -0
  14. package/dist/charts/diagrams/ArchitectureLayout.js +106 -0
  15. package/dist/charts/diagrams/ArchitectureLinkLabels.js +78 -0
  16. package/dist/charts/diagrams/ArchitectureModel.js +108 -0
  17. package/dist/charts/diagrams/ArchitectureRouter.js +348 -0
  18. package/dist/charts/diagrams/ArchitectureSamples.js +312 -0
  19. package/dist/charts/diagrams/ArchitectureShapes.js +130 -0
  20. package/dist/charts/diagrams/ArchitectureText.js +25 -0
  21. package/dist/charts/diagrams/DiagramText.js +38 -0
  22. package/dist/charts/diagrams/EntityRelationshipDiagram.js +465 -0
  23. package/dist/charts/diagrams/ErCrowsFoot.js +33 -0
  24. package/dist/charts/diagrams/ErSamples.js +205 -0
  25. package/dist/charts/diagrams/ErSchema.js +122 -0
  26. package/dist/charts/diagrams/LogicCircuit.js +154 -0
  27. package/dist/charts/diagrams/LogicCircuitLayout.js +600 -0
  28. package/dist/charts/diagrams/LogicCircuitSamples.js +66 -0
  29. package/dist/charts/diagrams/LogicGateDiagram.js +216 -0
  30. package/dist/charts/diagrams/LogicGateSymbol.js +113 -0
  31. package/dist/charts/diagrams/NetworkTopologyDiagram.js +247 -0
  32. package/dist/charts/diagrams/SequenceDiagram.js +351 -0
  33. package/dist/charts/diagrams/SequenceLayout.js +422 -0
  34. package/dist/charts/diagrams/SequenceModel.js +221 -0
  35. package/dist/charts/diagrams/TopologyLayout.js +143 -0
  36. package/dist/charts/diagrams/architectureEnums.js +50 -0
  37. package/dist/charts/diagrams/index.js +26 -0
  38. package/dist/charts/diagrams/logicEnums.js +42 -0
  39. package/dist/charts/diagrams/sequenceEnums.js +40 -0
  40. package/dist/charts/distribution/HistogramChart.js +25 -3
  41. package/dist/charts/distribution/index.js +1 -3
  42. package/dist/charts/earthscience/HovmollerDiagram.js +477 -0
  43. package/dist/charts/earthscience/HovmollerEnums.js +42 -0
  44. package/dist/charts/earthscience/HovmollerField.js +180 -0
  45. package/dist/charts/earthscience/HovmollerSample.js +40 -0
  46. package/dist/charts/earthscience/HovmollerSpeed.js +71 -0
  47. package/dist/charts/earthscience/index.js +10 -2
  48. package/dist/charts/engineering/ConstellationDiagram.js +26 -6
  49. package/dist/charts/engineering/EyeDiagram.js +2 -1
  50. package/dist/charts/engineering/PoleZeroPlot.js +32 -8
  51. package/dist/charts/financial/RelativeStrengthRotation.js +434 -0
  52. package/dist/charts/financial/RelativeStrengthRotationMath.js +71 -0
  53. package/dist/charts/financial/RelativeStrengthRotationSamples.js +27 -0
  54. package/dist/charts/financial/index.js +5 -3
  55. package/dist/charts/flow/index.js +1 -5
  56. package/dist/charts/frameworks/AnsoffMatrix.js +523 -0
  57. package/dist/charts/frameworks/FrameworkText.js +98 -0
  58. package/dist/charts/frameworks/NpsGauge.js +279 -0
  59. package/dist/charts/frameworks/NpsSurvey.js +58 -0
  60. package/dist/charts/frameworks/PerceptualMap.js +419 -0
  61. package/dist/charts/frameworks/PerceptualProjection.js +142 -0
  62. package/dist/charts/frameworks/PositioningSamples.js +191 -0
  63. package/dist/charts/frameworks/RaciMatrix.js +458 -0
  64. package/dist/charts/frameworks/RaciPlan.js +81 -0
  65. package/dist/charts/frameworks/RaciSamples.js +76 -0
  66. package/dist/charts/frameworks/SwotMatrix.js +358 -0
  67. package/dist/charts/frameworks/SwotSamples.js +85 -0
  68. package/dist/charts/frameworks/VanWestendorpChart.js +187 -0
  69. package/dist/charts/frameworks/VanWestendorpSurvey.js +197 -0
  70. package/dist/charts/frameworks/index.js +26 -0
  71. package/dist/charts/frameworks/raciEnums.js +30 -0
  72. package/dist/charts/frameworks/swotEnums.js +34 -0
  73. package/dist/charts/genomics/CircosPlot.js +1 -1
  74. package/dist/charts/geoflows/GeoFlowChart.js +2 -2
  75. package/dist/charts/geoflows/GeoFlowSample.js +2 -1
  76. package/dist/charts/geomaps/FloorPlanMap.js +324 -0
  77. package/dist/charts/geomaps/FloorPlanModel.js +179 -0
  78. package/dist/charts/geomaps/FloorPlanSamples.js +416 -0
  79. package/dist/charts/geomaps/GeoLand.js +2 -2
  80. package/dist/charts/geomaps/GeoSamples.js +2 -1
  81. package/dist/charts/geomaps/GraticuleMap.js +2 -2
  82. package/dist/charts/geomaps/RoomLabelFit.js +200 -0
  83. package/dist/charts/geomaps/TissotMap.js +2 -2
  84. package/dist/charts/geomaps/index.js +8 -4
  85. package/dist/charts/hierarchy/SunburstChart.js +17 -10
  86. package/dist/charts/hierarchy/index.js +2 -11
  87. package/dist/charts/indicators/BulletChart.js +8 -2
  88. package/dist/charts/indicators/SpeedometerChart.js +1 -1
  89. package/dist/charts/indicators/ThermometerChart.js +1 -1
  90. package/dist/charts/lifescience/LoessFit.js +113 -0
  91. package/dist/charts/lifescience/MaPlot.js +337 -0
  92. package/dist/charts/lifescience/MaPlotSamples.js +35 -0
  93. package/dist/charts/lifescience/OncoGlyph.js +137 -0
  94. package/dist/charts/lifescience/OncoPrint.js +373 -0
  95. package/dist/charts/lifescience/OncoPrintMatrix.js +107 -0
  96. package/dist/charts/lifescience/OncoPrintSamples.js +142 -0
  97. package/dist/charts/lifescience/PedigreeChart.js +306 -0
  98. package/dist/charts/lifescience/PedigreeLayout.js +261 -0
  99. package/dist/charts/lifescience/PedigreeRows.js +70 -0
  100. package/dist/charts/lifescience/PedigreeSamples.js +161 -0
  101. package/dist/charts/lifescience/PedigreeSymbol.js +47 -0
  102. package/dist/charts/lifescience/RamachandranPlot.js +364 -0
  103. package/dist/charts/lifescience/RamachandranRegions.js +2712 -0
  104. package/dist/charts/lifescience/RamachandranSamples.js +235 -0
  105. package/dist/charts/lifescience/index.js +25 -0
  106. package/dist/charts/lifescience/pedigreeEnums.js +21 -0
  107. package/dist/charts/lifescience/ramachandranEnums.js +34 -0
  108. package/dist/charts/linesareas/DualAxisChart.js +1 -1
  109. package/dist/charts/linesareas/LineEnums.js +1 -15
  110. package/dist/charts/linesareas/MultiLineChart.js +1 -1
  111. package/dist/charts/linesareas/TrendChart.js +15 -5
  112. package/dist/charts/linesareas/index.js +2 -6
  113. package/dist/charts/massive/MassiveChart.js +3 -2
  114. package/dist/charts/mathematical/FourierSeriesChart.js +7 -1
  115. package/dist/charts/mathematical/index.js +2 -6
  116. package/dist/charts/matrix/GridPlot.js +27 -2
  117. package/dist/charts/matrix/TilegramChart.js +2 -2
  118. package/dist/charts/mlcharts/AttentionGrid.js +79 -0
  119. package/dist/charts/mlcharts/AttentionHeatmap.js +329 -0
  120. package/dist/charts/mlcharts/AttentionModel.js +132 -0
  121. package/dist/charts/mlcharts/AttentionSamples.js +82 -0
  122. package/dist/charts/mlcharts/CellNumber.js +8 -0
  123. package/dist/charts/mlcharts/CurveDiagnosticSamples.js +95 -0
  124. package/dist/charts/mlcharts/CurvePickMark.js +31 -0
  125. package/dist/charts/mlcharts/DecisionBoundaryChart.js +316 -0
  126. package/dist/charts/mlcharts/DecisionSamples.js +60 -0
  127. package/dist/charts/mlcharts/DecisionSurface.js +130 -0
  128. package/dist/charts/mlcharts/DistinctTicks.js +13 -0
  129. package/dist/charts/mlcharts/ElbowMath.js +153 -0
  130. package/dist/charts/mlcharts/ElbowPlot.js +273 -0
  131. package/dist/charts/mlcharts/ExplainabilitySamples.js +70 -0
  132. package/dist/charts/mlcharts/FeatureImportanceChart.js +150 -0
  133. package/dist/charts/mlcharts/ImportanceRanking.js +98 -0
  134. package/dist/charts/mlcharts/LearningCurveChart.js +276 -0
  135. package/dist/charts/mlcharts/LearningCurveMath.js +92 -0
  136. package/dist/charts/mlcharts/PartialDependence.js +92 -0
  137. package/dist/charts/mlcharts/PartialDependencePlot.js +225 -0
  138. package/dist/charts/mlcharts/WideLabel.js +28 -0
  139. package/dist/charts/mlcharts/curveDiagnosticEnums.js +37 -0
  140. package/dist/charts/mlcharts/index.js +32 -0
  141. package/dist/charts/proportion/index.js +5 -9
  142. package/dist/charts/scatter/CategoryScatterBase.js +1 -4
  143. package/dist/charts/scatter/ConnectedScatter.js +7 -4
  144. package/dist/charts/scatter/MarginalScatter.js +13 -8
  145. package/dist/charts/scatter/index.js +4 -17
  146. package/dist/charts/scientific/SpectrogramChart.js +9 -3
  147. package/dist/charts/scientific/index.js +2 -2
  148. package/dist/charts/signals/index.js +3 -13
  149. package/dist/charts/statistics/LorenzCurve.js +7 -4
  150. package/dist/charts/threebars/Candlestick3DChart.js +18 -2
  151. package/dist/charts/threeglobe/GlobeChart.js +2 -2
  152. package/dist/charts/threeglobe/GlobeSamples.js +2 -1
  153. package/dist/charts/threeglobe/RingGlobe.js +1 -1
  154. package/dist/charts/threenetwork/ConeTreeChart.js +2 -1
  155. package/dist/charts/threenetwork/Gantt3DChart.js +3 -2
  156. package/dist/charts/threesurfaces/Ridgeline3DChart.js +22 -4
  157. package/dist/charts/threevolume/LorenzAttractor.js +19 -0
  158. package/dist/charts/threevolume/VolumeRaycastChart.js +4 -3
  159. package/dist/charts/threevolume/volumeEnums.js +1 -0
  160. package/dist/charts/timeline/index.js +2 -10
  161. package/dist/chunk-753UBLV2.cjs +33 -0
  162. package/dist/chunk-VX54A7KA.cjs +17 -0
  163. package/dist/{chunk-W5GAAAZY.cjs → chunk-WAODDPYX.cjs} +24874 -17620
  164. package/dist/chunk-Z3JPIJSC.cjs +338 -0
  165. package/dist/core/CartesianChart.js +87 -11
  166. package/dist/core/Chart.js +63 -15
  167. package/dist/core/Chart2D.js +54 -17
  168. package/dist/core/Chart3D.js +56 -20
  169. package/dist/core/ChartExportError.js +2 -0
  170. package/dist/core/ChartExporter.js +50 -8
  171. package/dist/core/ColumnCheck.js +53 -0
  172. package/dist/core/Context2D.js +29 -15
  173. package/dist/core/Context3D.js +7 -2
  174. package/dist/core/ExcelWriter.js +152 -0
  175. package/dist/core/ExportText.js +28 -0
  176. package/dist/core/MarkOrigin.js +88 -0
  177. package/dist/core/MarkStyle.js +294 -0
  178. package/dist/core/Palette.js +27 -7
  179. package/dist/core/PdfPage.js +13 -0
  180. package/dist/core/PdfWriter.js +245 -0
  181. package/dist/core/Scales.js +52 -2
  182. package/dist/core/SceneOverlay.js +4 -4
  183. package/dist/core/TextFit.js +37 -0
  184. package/dist/core/Theme.js +28 -1
  185. package/dist/core/enums.js +33 -1
  186. package/dist/core/pathMorph.js +15 -0
  187. package/dist/data/SampleData.js +1 -17
  188. package/dist/data/WorldSample.js +23 -0
  189. package/dist/index.cjs +810 -691
  190. package/dist/index.d.cts +8390 -2925
  191. package/dist/index.d.ts +8392 -2927
  192. package/dist/index.js +106 -56
  193. package/dist/react/ChartView.js +18 -8
  194. package/dist/react/GraphHandle.js +2 -0
  195. package/dist/react/components.js +74 -116
  196. package/package.json +13 -3
  197. package/dist/catalog-4UP2YFNR.cjs +0 -7
  198. package/dist/charts/bars/BulletBar.js +0 -184
  199. package/dist/charts/bars/ErrorBarColumn.js +0 -147
  200. package/dist/charts/bars/HistogramBar.js +0 -217
  201. package/dist/charts/bars/MarimekkoBar.js +0 -191
  202. package/dist/charts/bars/ParetoBar.js +0 -143
  203. package/dist/charts/comparison/PanelChart.js +0 -70
  204. package/dist/charts/comparison/SmallMultipleBars.js +0 -134
  205. package/dist/charts/comparison/SpanChart.js +0 -118
  206. package/dist/charts/comparison/TrellisChart.js +0 -158
  207. package/dist/charts/distribution/RidgeSlabs3DChart.js +0 -169
  208. package/dist/charts/financial/Candlestick3D.js +0 -157
  209. package/dist/charts/flow/BumpFlow.js +0 -212
  210. package/dist/charts/flow/StreamFlow.js +0 -174
  211. package/dist/charts/geomaps/ThiessenMap.js +0 -106
  212. package/dist/charts/hierarchy/ConeTree3D.js +0 -135
  213. package/dist/charts/hierarchy/Hierarchy3DChart.js +0 -27
  214. package/dist/charts/hierarchy/SpherePack3D.js +0 -81
  215. package/dist/charts/hierarchy/SunburstTower3D.js +0 -93
  216. package/dist/charts/hierarchy/TreemapSkyline3D.js +0 -98
  217. package/dist/charts/linesareas/ConnectedScatterTime.js +0 -206
  218. package/dist/charts/linesareas/SmallMultipleLines.js +0 -153
  219. package/dist/charts/mathematical/ParametricSurface3D.js +0 -234
  220. package/dist/charts/mathematical/StrangeAttractor3D.js +0 -251
  221. package/dist/charts/proportion/ConeFunnel.js +0 -126
  222. package/dist/charts/proportion/ExtrudedPieChart.js +0 -123
  223. package/dist/charts/scatter/BeeswarmScatter.js +0 -151
  224. package/dist/charts/scatter/BinnedScatterBase.js +0 -51
  225. package/dist/charts/scatter/ContourScatter.js +0 -116
  226. package/dist/charts/scatter/GridBinScatter.js +0 -98
  227. package/dist/charts/scatter/HexbinScatter.js +0 -74
  228. package/dist/charts/scatter/StripScatter.js +0 -107
  229. package/dist/charts/scatter/SunflowerScatter.js +0 -109
  230. package/dist/charts/signals/ConstellationChart.js +0 -153
  231. package/dist/charts/signals/CorrelogramChart.js +0 -171
  232. package/dist/charts/signals/EyeDiagramChart.js +0 -155
  233. package/dist/charts/signals/PoleZeroChart.js +0 -164
  234. package/dist/charts/signals/SpectrogramChart.js +0 -79
  235. package/dist/charts/timeline/CalendarSkylineChart.js +0 -128
  236. package/dist/charts/timeline/ScheduleBlocksChart.js +0 -119
  237. package/dist/charts/timeline/SpiralTimeline.js +0 -154
  238. package/dist/charts/timeline/StreamTimeline.js +0 -141
@@ -0,0 +1,419 @@
1
+ import { Chart2D } from "../../core/Chart2D.js";
2
+ import { ChartFamily, DataShape, Dimension } from "../../core/enums.js";
3
+ import { PerceptualMethod, PerceptualProjection } from "./PerceptualProjection.js";
4
+ import { PositioningSamples } from "./PositioningSamples.js";
5
+ import { max, min, scaleSqrt, symbol, symbolDiamond } from "d3";
6
+ // src/charts/frameworks/PerceptualMap.ts
7
+ var POLE_ROW = 16;
8
+ var PLAIN_RADIUS = 6;
9
+ var MIN_RADIUS = 4;
10
+ var CHAR = 6.2;
11
+ var GUTTER = 6;
12
+ var IDEAL_AREA = 110;
13
+ var clip = (text, room) => {
14
+ const fit = Math.max(1, Math.floor(room / CHAR));
15
+ return text.length <= fit ? text : `${text.slice(0, Math.max(1, fit - 1))}…`;
16
+ };
17
+ var spotBox = ({ x, y, anchor }, width) => {
18
+ const x0 = anchor === "start" ? x : anchor === "end" ? x - width : x - width / 2;
19
+ return {
20
+ x0,
21
+ y0: y - 7,
22
+ x1: x0 + width,
23
+ y1: y + 7
24
+ };
25
+ };
26
+ var hits = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
27
+ /**
28
+ * Shows where brands sit in customers' minds. Two attribute axes cross at the midpoint, with bubble area for share,
29
+ * ideal rows as projected demand points, and attribute vectors. The direct map reads two rated attributes and fits the others
30
+ * by regression (property fitting: Carroll and Chang's PROFIT). The biplots follow Gabriel (1971) on standardised ratings and keep
31
+ * one unit on both axes: the form biplot favours distances between brands, the covariance biplot angles between attributes.
32
+ */
33
+ var PerceptualMap = class extends Chart2D {
34
+ static meta = {
35
+ id: "perceptual-map",
36
+ name: "Perceptual map",
37
+ family: ChartFamily.Relationship,
38
+ dimension: Dimension.Two,
39
+ shape: DataShape.Table,
40
+ description: "Positions brands on two attribute axes to reveal competitors, gaps and the distance to what customers want.",
41
+ tags: [
42
+ "positioning",
43
+ "brand map",
44
+ "biplot",
45
+ "marketing",
46
+ "ideal point"
47
+ ],
48
+ sample: (seed) => PositioningSamples.perceptions(seed),
49
+ variants: {
50
+ method: [
51
+ PerceptualMethod.Direct,
52
+ PerceptualMethod.FormBiplot,
53
+ PerceptualMethod.CovarianceBiplot
54
+ ],
55
+ sized: [true, false],
56
+ ideal: [false, true],
57
+ vectors: [false, true]
58
+ }
59
+ };
60
+ #dropped = false;
61
+ defaults() {
62
+ return {
63
+ name: "brand",
64
+ x: "Price",
65
+ y: "Quality",
66
+ size: "share",
67
+ role: "role",
68
+ method: PerceptualMethod.Direct,
69
+ sized: true,
70
+ ideal: false,
71
+ vectors: false
72
+ };
73
+ }
74
+ plotMargin() {
75
+ return {
76
+ top: 6,
77
+ right: 12,
78
+ bottom: 6,
79
+ left: 12
80
+ };
81
+ }
82
+ /** Labels name each brand, so the legend returns only when one had no room. */
83
+ legendItems() {
84
+ return this.#dropped ? super.legendItems() : [];
85
+ }
86
+ get #solution() {
87
+ const { data, name, x, y, size, role, attributes, method } = this.options;
88
+ return PerceptualProjection.solve(Array.isArray(data) ? data : [], {
89
+ name,
90
+ x,
91
+ y,
92
+ size,
93
+ role,
94
+ ...attributes ? { attributes } : {}
95
+ }, method);
96
+ }
97
+ #direct(values, domain) {
98
+ const low = min(values) ?? 0, high = max(values) ?? 0;
99
+ const centre = domain ? (domain[0] + domain[1]) / 2 : (low + high) / 2;
100
+ return {
101
+ centre,
102
+ reach: Math.max(high - centre, centre - low, domain ? Math.abs(domain[1] - domain[0]) / 2 : 0) || 1
103
+ };
104
+ }
105
+ #frame(width, height, inset, places) {
106
+ const midX = width / 2, midY = height / 2, halfX = midX - inset, halfY = midY - inset - POLE_ROW;
107
+ if (this.options.method !== PerceptualMethod.Direct) {
108
+ const unit = Math.min(halfX / (max(places, (p) => Math.abs(p.x)) || 1), halfY / (max(places, (p) => Math.abs(p.y)) || 1));
109
+ return {
110
+ px: (x) => midX + x * unit,
111
+ py: (y) => midY - y * unit,
112
+ unitX: unit,
113
+ unitY: unit
114
+ };
115
+ }
116
+ const across = this.#direct(places.map((p) => p.x), this.options.xDomain), up = this.#direct(places.map((p) => p.y), this.options.yDomain);
117
+ const unitX = halfX / across.reach, unitY = halfY / up.reach;
118
+ return {
119
+ px: (x) => midX + (x - across.centre) * unitX,
120
+ py: (y) => midY - (y - up.centre) * unitY,
121
+ unitX,
122
+ unitY
123
+ };
124
+ }
125
+ #radius(solution, width, height) {
126
+ if (!this.options.sized) return () => PLAIN_RADIUS;
127
+ const top = max(solution.brands, (place) => place.size ?? 0) ?? 0;
128
+ if (top <= 0) return () => PLAIN_RADIUS;
129
+ const area = scaleSqrt().domain([0, top]).range([0, Math.max(12, Math.min(26, Math.min(width, height) * .08))]);
130
+ return (place) => Math.max(MIN_RADIUS, area(Math.max(0, place.size ?? 0)));
131
+ }
132
+ #poles(solution, width, height) {
133
+ const { method, x, y, xPoles, yPoles } = this.options;
134
+ const dimension = (axis) => solution.explained ? `Dim ${axis + 1} (${Math.round(solution.explained[axis] * 100)}%)` : "";
135
+ const [left, right] = xPoles ?? (method === PerceptualMethod.Direct ? [`Low ${x}`, `High ${x}`] : ["", dimension(0)]);
136
+ const [bottom, top] = yPoles ?? (method === PerceptualMethod.Direct ? [`Low ${y}`, `High ${y}`] : ["", dimension(1)]);
137
+ const across = width / 2 - GUTTER, up = width - 12;
138
+ return [
139
+ {
140
+ key: "left",
141
+ text: clip(left, across),
142
+ x: 0,
143
+ y: height / 2 - 9,
144
+ anchor: "start"
145
+ },
146
+ {
147
+ key: "right",
148
+ text: clip(right, across),
149
+ x: width,
150
+ y: height / 2 - 9,
151
+ anchor: "end"
152
+ },
153
+ {
154
+ key: "top",
155
+ text: clip(top, up),
156
+ x: width / 2,
157
+ y: 8,
158
+ anchor: "middle"
159
+ },
160
+ {
161
+ key: "bottom",
162
+ text: clip(bottom, up),
163
+ x: width / 2,
164
+ y: height - 7,
165
+ anchor: "middle"
166
+ }
167
+ ].filter((pole) => pole.text);
168
+ }
169
+ /** Attribute vectors in pixels. Screen direction stays normal to the fitted contours even when the two axes have different units. */
170
+ #tips(arrows, frame, reach) {
171
+ const direct = this.options.method === PerceptualMethod.Direct;
172
+ const raw = arrows.map((arrow) => direct ? {
173
+ arrow,
174
+ x: arrow.x / frame.unitX,
175
+ y: -arrow.y / frame.unitY
176
+ } : {
177
+ arrow,
178
+ x: arrow.x * frame.unitX,
179
+ y: -arrow.y * frame.unitY
180
+ });
181
+ const longest = max(raw, (tip) => Math.hypot(tip.x, tip.y)) || 1;
182
+ return raw.map((tip) => {
183
+ const scale = direct ? reach * tip.arrow.strength / (Math.hypot(tip.x, tip.y) || 1) : reach / longest;
184
+ return {
185
+ arrow: tip.arrow,
186
+ x: tip.x * scale,
187
+ y: tip.y * scale
188
+ };
189
+ });
190
+ }
191
+ draw(ctx) {
192
+ const { width, height } = ctx.plot;
193
+ const { ideal, vectors, size, sized } = this.options;
194
+ const solution = this.#solution;
195
+ const shownIdeals = ideal ? solution.ideals : [];
196
+ const radius = this.#radius(solution, width, height);
197
+ const largest = max(solution.brands, radius) ?? PLAIN_RADIUS;
198
+ const frame = this.#frame(width, height, largest + 6, [...solution.brands, ...shownIdeals]);
199
+ const originX = width / 2, originY = height / 2;
200
+ const blocked = solution.brands.map((place) => {
201
+ const r = radius(place), x = frame.px(place.x), y = frame.py(place.y);
202
+ return {
203
+ x0: x - r,
204
+ y0: y - r,
205
+ x1: x + r,
206
+ y1: y + r
207
+ };
208
+ });
209
+ const poles = this.#poles(solution, width, height).map((pole) => {
210
+ if (pole.key !== "left" && pole.key !== "right") return pole;
211
+ const box = spotBox(pole, pole.text.length * CHAR);
212
+ return blocked.some((mark) => hits(box, mark)) ? {
213
+ ...pole,
214
+ y: height / 2 + 11
215
+ } : pole;
216
+ });
217
+ blocked.push(...poles.map((pole) => spotBox(pole, pole.text.length * CHAR)));
218
+ const area = sized && solution.brands.some((place) => place.size !== null) ? `Area = ${size}` : "";
219
+ this.#drawAxes(ctx, originX, originY);
220
+ const tips = vectors ? this.#tips(solution.arrows, frame, Math.min(width / 2, height / 2 - POLE_ROW) * .78) : [];
221
+ this.#drawVectors(ctx, tips, originX, originY);
222
+ this.#drawBrands(ctx, solution.brands, frame, radius);
223
+ this.#drawIdeals(ctx, shownIdeals, frame);
224
+ const labels = [...this.#placeAll(shownIdeals.map((place) => ({
225
+ key: `ideal/${place.name}`,
226
+ text: place.name,
227
+ tone: "ink",
228
+ weight: 600,
229
+ spots: this.#around(frame.px(place.x), frame.py(place.y), 8)
230
+ })), width, height, blocked), ...this.#placeAll(tips.map((tip) => ({
231
+ key: `vector/${tip.arrow.name}`,
232
+ text: tip.arrow.name,
233
+ tone: "secondary",
234
+ weight: 600,
235
+ spots: this.#beyond(originX + tip.x, originY + tip.y, tip)
236
+ })), width, height, blocked)];
237
+ const brands = [...solution.brands].sort((a, b) => radius(b) - radius(a));
238
+ const placed = this.#placeAll(brands.map((place, i) => ({
239
+ key: `brand/${place.name}/${i}`,
240
+ text: place.name,
241
+ tone: "ink",
242
+ weight: 400,
243
+ spots: this.#around(frame.px(place.x), frame.py(place.y), radius(place))
244
+ })), width, height, blocked);
245
+ this.#dropped = placed.length < brands.length;
246
+ this.#drawPoles(ctx, poles);
247
+ this.#drawLabels(ctx, [...labels, ...placed]);
248
+ this.#drawAreaNote(ctx, area, width, height, blocked);
249
+ }
250
+ #around(x, y, r) {
251
+ return [
252
+ {
253
+ x: x + r + 4,
254
+ y,
255
+ anchor: "start"
256
+ },
257
+ {
258
+ x: x - r - 4,
259
+ y,
260
+ anchor: "end"
261
+ },
262
+ {
263
+ x,
264
+ y: y - r - 9,
265
+ anchor: "middle"
266
+ },
267
+ {
268
+ x,
269
+ y: y + r + 9,
270
+ anchor: "middle"
271
+ }
272
+ ];
273
+ }
274
+ #beyond(x, y, tip) {
275
+ const anchor = tip.x >= 0 ? "start" : "end", side = tip.x >= 0 ? 6 : -6, lift = tip.y >= 0 ? 9 : -9;
276
+ return [
277
+ {
278
+ x: x + side,
279
+ y: y + lift / 3,
280
+ anchor
281
+ },
282
+ {
283
+ x,
284
+ y: y + lift,
285
+ anchor: "middle"
286
+ },
287
+ {
288
+ x: x - side,
289
+ y: y + lift,
290
+ anchor: tip.x >= 0 ? "end" : "start"
291
+ }
292
+ ];
293
+ }
294
+ /** First free spot for each label in turn. Labels with no room are dropped, and stay readable in the tooltip. */
295
+ #placeAll(items, width, height, blocked) {
296
+ return items.flatMap(({ spots, ...item }) => {
297
+ const text = clip(item.text, Math.max(48, width * .34)), room = text.length * CHAR;
298
+ const spot = spots.find((candidate) => {
299
+ const box = spotBox(candidate, room);
300
+ return box.x0 >= 0 && box.x1 <= width && box.y0 >= 0 && box.y1 <= height && !blocked.some((mark) => hits(box, mark));
301
+ });
302
+ if (!spot) return [];
303
+ blocked.push(spotBox(spot, room));
304
+ return [{
305
+ ...item,
306
+ ...spot,
307
+ text
308
+ }];
309
+ });
310
+ }
311
+ #drawAxes(ctx, x, y) {
312
+ const { width, height } = ctx.plot;
313
+ const spans = [{
314
+ key: "x",
315
+ x1: 0,
316
+ y1: y,
317
+ x2: width,
318
+ y2: y
319
+ }, {
320
+ key: "y",
321
+ x1: x,
322
+ y1: POLE_ROW,
323
+ x2: x,
324
+ y2: height - POLE_ROW
325
+ }];
326
+ const lines = ctx.marks("axes", "line", spans, (d) => d.key).attr("stroke", ctx.theme.axis).attr("stroke-width", 1);
327
+ const place = {
328
+ x1: (d) => d.x1,
329
+ y1: (d) => d.y1,
330
+ x2: (d) => d.x2,
331
+ y2: (d) => d.y2
332
+ };
333
+ ctx.set(lines, place, place);
334
+ }
335
+ /** Above the marks with a halo, since a brand may sit at the end of an axis on both sides of it. */
336
+ #drawPoles(ctx, poles) {
337
+ const texts = ctx.text(ctx.marks("poles", "text", poles, (d) => d.key).attr("text-anchor", (d) => d.anchor).attr("dy", "0.32em").attr("font-weight", 600).text((d) => d.text)).attr("stroke", ctx.theme.surface).attr("stroke-width", 3).attr("paint-order", "stroke").attr("stroke-linejoin", "round");
338
+ ctx.set(texts, {
339
+ x: (d) => d.x,
340
+ y: (d) => d.y
341
+ }, {
342
+ x: (d) => d.x,
343
+ y: (d) => d.y
344
+ });
345
+ }
346
+ #drawVectors(ctx, tips, x, y) {
347
+ const path = ({ x: dx, y: dy }, scale) => {
348
+ const tx = x + dx * scale, ty = y + dy * scale, angle = Math.atan2(dy, dx), head = 7 * scale;
349
+ const wing = (turn) => `${tx - head * Math.cos(angle + turn)},${ty - head * Math.sin(angle + turn)}`;
350
+ return `M${x},${y}L${tx},${ty}M${wing(.45)}L${tx},${ty}L${wing(-.45)}`;
351
+ };
352
+ const lines = ctx.marks("vectors", "path", tips, (tip) => tip.arrow.name).attr("fill", "none").attr("stroke", ctx.theme.inkMuted).attr("stroke-width", 2).attr("stroke-linecap", "round").attr("stroke-linejoin", "round");
353
+ ctx.set(lines, { d: (tip) => path(tip, 1) }, { d: (tip) => path(tip, 0) });
354
+ ctx.hover(lines, ({ arrow }) => ({
355
+ title: arrow.name,
356
+ rows: [["Fit with map (R)", +arrow.strength.toFixed(2)]]
357
+ }));
358
+ }
359
+ #drawBrands(ctx, brands, frame, radius) {
360
+ const { x, y, size, method } = this.options;
361
+ const direct = method === PerceptualMethod.Direct;
362
+ const ordered = [...brands].sort((a, b) => radius(b) - radius(a));
363
+ const dots = ctx.marks("brands", "circle", ordered, (d, i) => `${d.name}/${i}`).attr("fill", (d) => ctx.palette.color(d.name)).attr("fill-opacity", .9).attr("stroke", ctx.theme.surface).attr("stroke-width", 2);
364
+ ctx.set(dots, {
365
+ cx: (d) => frame.px(d.x),
366
+ cy: (d) => frame.py(d.y),
367
+ r: radius
368
+ }, { r: 0 }, 30);
369
+ ctx.hover(dots, (d) => ({
370
+ title: d.name,
371
+ color: ctx.palette.color(d.name),
372
+ rows: [
373
+ [direct ? x : "Dim 1", +d.x.toFixed(2)],
374
+ [direct ? y : "Dim 2", +d.y.toFixed(2)],
375
+ ...d.size === null ? [] : [[size, d.size]]
376
+ ]
377
+ }));
378
+ }
379
+ #drawIdeals(ctx, ideals, frame) {
380
+ const diamond = symbol(symbolDiamond, IDEAL_AREA)();
381
+ const marks = ctx.marks("ideals", "path", ideals, (d, i) => `${d.name}/${i}`).attr("fill", ctx.theme.ink).attr("stroke", ctx.theme.surface).attr("stroke-width", 2);
382
+ const at = (d) => `translate(${frame.px(d.x)},${frame.py(d.y)})`;
383
+ ctx.set(marks, {
384
+ d: diamond,
385
+ transform: at
386
+ }, { transform: at });
387
+ ctx.hover(marks, (d) => ({
388
+ title: d.name,
389
+ rows: [["Map x", +d.x.toFixed(2)], ["Map y", +d.y.toFixed(2)]]
390
+ }));
391
+ }
392
+ #drawLabels(ctx, labels) {
393
+ const texts = ctx.marks("labels", "text", labels, (d) => d.key).attr("text-anchor", (d) => d.anchor).attr("dy", "0.32em").attr("font-size", 11).attr("font-family", ctx.theme.fontFamily).attr("font-weight", (d) => d.weight).attr("fill", (d) => d.tone === "ink" ? ctx.theme.ink : ctx.theme.inkSecondary).attr("stroke", ctx.theme.surface).attr("stroke-width", 3).attr("paint-order", "stroke").attr("stroke-linejoin", "round").text((d) => d.text);
394
+ ctx.set(texts, {
395
+ x: (d) => d.x,
396
+ y: (d) => d.y
397
+ }, {
398
+ x: (d) => d.x,
399
+ y: (d) => d.y
400
+ });
401
+ }
402
+ #drawAreaNote(ctx, text, width, height, blocked) {
403
+ const spot = {
404
+ x: width,
405
+ y: height - 7,
406
+ anchor: "end"
407
+ };
408
+ const free = text && !blocked.some((mark) => hits(spotBox(spot, text.length * CHAR), mark));
409
+ const notes = ctx.text(ctx.marks("area-note", "text", free ? [text] : [], (d) => d).attr("text-anchor", "end").attr("dy", "0.32em").text((d) => d), 10.5, "muted");
410
+ ctx.set(notes, {
411
+ x: spot.x,
412
+ y: spot.y
413
+ }, {
414
+ x: spot.x,
415
+ y: spot.y
416
+ });
417
+ }
418
+ };
419
+ export { PerceptualMap };
@@ -0,0 +1,142 @@
1
+ import { MultivariateMath } from "../multivariate/MultivariateMath.js";
2
+ import { deviation, mean, sum } from "d3";
3
+ // src/charts/frameworks/PerceptualProjection.ts
4
+ var PerceptualMethod = /* @__PURE__ */ function(PerceptualMethod) {
5
+ PerceptualMethod["Direct"] = "direct";
6
+ PerceptualMethod["FormBiplot"] = "form-biplot";
7
+ PerceptualMethod["CovarianceBiplot"] = "covariance-biplot";
8
+ return PerceptualMethod;
9
+ }({});
10
+ var PerceptualRole = /* @__PURE__ */ function(PerceptualRole) {
11
+ PerceptualRole["Brand"] = "brand";
12
+ PerceptualRole["Ideal"] = "ideal";
13
+ return PerceptualRole;
14
+ }({});
15
+ var MIN_FIT_ROWS = 4;
16
+ var number = (value) => {
17
+ if (typeof value === "number") return Number.isFinite(value) ? value : NaN;
18
+ return typeof value === "string" && value.trim() ? Number(value) : NaN;
19
+ };
20
+ /** Turns brand rows into map coordinates: raw attribute pairs, or a two-dimensional biplot of standardised ratings. */
21
+ var PerceptualProjection = class {
22
+ /** Places every row, keeping ideal rows out of the fit so that they are supplementary points. */
23
+ static solve(rows, columns, method) {
24
+ const clean = rows.filter((row) => row !== null && typeof row === "object");
25
+ const isIdeal = (row) => String(row[columns.role] ?? "").trim().toLowerCase() === "ideal";
26
+ const brands = clean.filter((row) => !isIdeal(row)), ideals = clean.filter(isIdeal);
27
+ return method === "direct" ? this.#direct(brands, ideals, columns) : this.#biplot(brands, ideals, columns, method);
28
+ }
29
+ /** Least-squares fit of `value` on map position, the property fitting of PROFIT (Carroll and Chang). Null when underdetermined. */
30
+ static fit(xs, ys, values) {
31
+ const kept = values.flatMap((value, i) => Number.isFinite(value) ? [i] : []);
32
+ if (kept.length < MIN_FIT_ROWS) return null;
33
+ const mx = mean(kept, (i) => xs[i]), my = mean(kept, (i) => ys[i]), mv = mean(kept, (i) => values[i]);
34
+ const cross = (a, b) => sum(kept, (i) => a(i) * b(i));
35
+ const dx = (i) => xs[i] - mx, dy = (i) => ys[i] - my, dv = (i) => values[i] - mv;
36
+ const xx = cross(dx, dx), yy = cross(dy, dy), xy = cross(dx, dy), xv = cross(dx, dv), yv = cross(dy, dv), vv = cross(dv, dv);
37
+ const determinant = xx * yy - xy * xy;
38
+ if (!vv || determinant <= 1e-12 * xx * yy) return null;
39
+ const x = (yy * xv - xy * yv) / determinant, y = (xx * yv - xy * xv) / determinant;
40
+ return {
41
+ x,
42
+ y,
43
+ strength: Math.sqrt(Math.max(0, Math.min(1, (x * xv + y * yv) / vv)))
44
+ };
45
+ }
46
+ static #place(row, x, y, columns) {
47
+ const size = number(row[columns.size]);
48
+ return {
49
+ name: String(row[columns.name] ?? "Unnamed"),
50
+ x,
51
+ y,
52
+ size: Number.isNaN(size) ? null : size,
53
+ row
54
+ };
55
+ }
56
+ /** Columns holding at least two distinct numbers across the brands. */
57
+ static #attributes(brands, columns, skip) {
58
+ return (columns.attributes ?? [...new Set(brands.flatMap((row) => Object.keys(row)))]).filter((name) => !skip.includes(name) && new Set(brands.map((row) => number(row[name])).filter(Number.isFinite)).size >= 2);
59
+ }
60
+ static #direct(brands, ideals, columns) {
61
+ const at = (row) => this.#place(row, number(row[columns.x]), number(row[columns.y]), columns);
62
+ const usable = ({ x, y }) => Number.isFinite(x) && Number.isFinite(y);
63
+ const placed = brands.map(at).filter(usable);
64
+ const xs = placed.map((place) => place.x), ys = placed.map((place) => place.y);
65
+ const arrows = this.#attributes(placed.map((place) => place.row), columns, [
66
+ columns.name,
67
+ columns.x,
68
+ columns.y,
69
+ columns.size,
70
+ columns.role
71
+ ]).flatMap((name) => {
72
+ const fitted = this.fit(xs, ys, placed.map((place) => number(place.row[name])));
73
+ return fitted ? [{
74
+ name,
75
+ ...fitted
76
+ }] : [];
77
+ });
78
+ return {
79
+ brands: placed,
80
+ ideals: ideals.map(at).filter(usable),
81
+ arrows,
82
+ explained: null
83
+ };
84
+ }
85
+ /** Gabriel's biplot of the brand-by-attribute table after standardising each attribute, from the correlation matrix. */
86
+ static #biplot(brands, ideals, columns, method) {
87
+ const empty = {
88
+ brands: [],
89
+ ideals: [],
90
+ arrows: [],
91
+ explained: null
92
+ };
93
+ const scored = this.#attributes(brands, columns, [
94
+ columns.name,
95
+ columns.size,
96
+ columns.role
97
+ ]);
98
+ const vector = (row) => scored.map((name) => number(row[name]));
99
+ const complete = brands.filter((row) => vector(row).every(Number.isFinite));
100
+ const attributes = this.#attributes(complete, {
101
+ ...columns,
102
+ attributes: scored
103
+ }, []);
104
+ if (complete.length < 2 || attributes.length < 2) return empty;
105
+ const matrix = complete.map((row) => attributes.map((name) => number(row[name])));
106
+ const { scores, loadings, eigenvalues, explained } = MultivariateMath.pca(matrix, true);
107
+ const stats = attributes.map((_, c) => {
108
+ const column = matrix.map((row) => row[c]);
109
+ return {
110
+ centre: mean(column),
111
+ spread: deviation(column) || 1
112
+ };
113
+ });
114
+ const root = [Math.sqrt(eigenvalues[0]), Math.sqrt(eigenvalues[1] ?? 0)];
115
+ const covariance = method === "covariance-biplot";
116
+ const rowScale = (axis) => covariance ? 1 / (root[axis] || 1) : 1;
117
+ const columnScale = (axis) => covariance ? root[axis] : 1;
118
+ const place = (row, score) => this.#place(row, score[0] * rowScale(0), (score[1] ?? 0) * rowScale(1), columns);
119
+ const supplementary = ideals.flatMap((row) => {
120
+ const values = attributes.map((name) => number(row[name]));
121
+ if (!values.every(Number.isFinite)) return [];
122
+ const z = values.map((value, c) => (value - stats[c].centre) / stats[c].spread);
123
+ return [place(row, loadings.slice(0, 2).map((axis) => sum(axis, (value, c) => value * z[c])))];
124
+ });
125
+ const arrows = attributes.map((name, c) => {
126
+ const a = loadings[0][c], b = loadings[1]?.[c] ?? 0;
127
+ return {
128
+ name,
129
+ x: a * columnScale(0),
130
+ y: b * columnScale(1),
131
+ strength: Math.min(1, Math.hypot(a * root[0], b * root[1]))
132
+ };
133
+ });
134
+ return {
135
+ brands: complete.map((row, i) => place(row, scores[i])),
136
+ ideals: supplementary,
137
+ arrows,
138
+ explained: [explained[0], explained[1] ?? 0]
139
+ };
140
+ }
141
+ };
142
+ export { PerceptualMethod, PerceptualProjection, PerceptualRole };