@@ -11,11 +11,13 @@ import type {
1111 IconElement ,
1212 EmbedElement ,
1313 ChartElement ,
14+ ConnectorElement ,
1415 GroupElement ,
1516 UnknownElement ,
1617 ShadowSpec ,
1718 GlowSpec ,
1819} from "@/lib/types" ;
20+ import { buildChartOption } from "@/lib/chart/chartOption" ;
1921
2022export function ElementView ( {
2123 el,
@@ -43,6 +45,8 @@ export function ElementView({
4345 return < EmbedView el = { el } /> ;
4446 case "chart" :
4547 return < ChartView el = { el } /> ;
48+ case "connector" :
49+ return < ConnectorView el = { el } /> ;
4650 case "group" :
4751 return < GroupView el = { el } editing = { editing } onTextCommit = { onTextCommit } /> ;
4852 case "unknown" :
@@ -1014,6 +1018,92 @@ function LineView({ el }: { el: LineElement }) {
10141018 ) ;
10151019}
10161020
1021+ /**
1022+ * Render a connector — a first-class line between two anchor corners of its
1023+ * bounding box. `flipH`/`flipV` pick the diagonal; `kind` selects straight /
1024+ * bent (elbow) / curved geometry; `startArrow`/`endArrow` add arrowheads.
1025+ * Mirrors the `<p:cxnSp>` the writer emits so the editor preview matches save.
1026+ */
1027+ function ConnectorView ( { el } : { el : ConnectorElement } ) {
1028+ const uid = useId ( ) . replace ( / [ ^ a - z A - Z 0 - 9 _ - ] / g, "" ) ;
1029+ const w = Math . abs ( el . w ) || 1 ;
1030+ const h = Math . abs ( el . h ) || 1 ;
1031+ const sx = el . flipH ? w : 0 ;
1032+ const sy = el . flipV ? h : 0 ;
1033+ const ex = el . flipH ? 0 : w ;
1034+ const ey = el . flipV ? 0 : h ;
1035+
1036+ let d : string ;
1037+ if ( el . kind === "bent" ) {
1038+ const mx = ( sx + ex ) / 2 ;
1039+ d = `M ${ sx } ${ sy } L ${ mx } ${ sy } L ${ mx } ${ ey } L ${ ex } ${ ey } ` ;
1040+ } else if ( el . kind === "curved" ) {
1041+ const mx = ( sx + ex ) / 2 ;
1042+ d = `M ${ sx } ${ sy } C ${ mx } ${ sy } ${ mx } ${ ey } ${ ex } ${ ey } ` ;
1043+ } else {
1044+ d = `M ${ sx } ${ sy } L ${ ex } ${ ey } ` ;
1045+ }
1046+
1047+ const startId = `cxn-s-${ uid } ` ;
1048+ const endId = `cxn-e-${ uid } ` ;
1049+ const hasStart = el . startArrow && el . startArrow !== "none" ;
1050+ const hasEnd = el . endArrow && el . endArrow !== "none" ;
1051+
1052+ return (
1053+ < svg
1054+ viewBox = { `0 0 ${ w } ${ h } ` }
1055+ preserveAspectRatio = "none"
1056+ width = "100%"
1057+ height = "100%"
1058+ style = { { overflow : "visible" , ...effectStyle ( el . shadow , el . glow , "filter" ) } }
1059+ >
1060+ < defs >
1061+ { hasStart && < ArrowMarker id = { startId } color = { el . stroke } orient = "auto-start-reverse" /> }
1062+ { hasEnd && < ArrowMarker id = { endId } color = { el . stroke } orient = "auto" /> }
1063+ </ defs >
1064+ < path
1065+ d = { d }
1066+ fill = "none"
1067+ stroke = { el . stroke }
1068+ strokeWidth = { el . strokeWidth }
1069+ strokeDasharray = { dashStyleFor ( el . dashType , el . strokeWidth ) . dasharray }
1070+ strokeLinecap = "round"
1071+ strokeLinejoin = "round"
1072+ vectorEffect = "non-scaling-stroke"
1073+ markerStart = { hasStart ? `url(#${ startId } )` : undefined }
1074+ markerEnd = { hasEnd ? `url(#${ endId } )` : undefined }
1075+ />
1076+ </ svg >
1077+ ) ;
1078+ }
1079+
1080+ /** A reusable triangular arrowhead marker. The PPTX writer encodes the exact
1081+ * arrowhead family; the preview uses a single triangular glyph. */
1082+ function ArrowMarker ( {
1083+ id,
1084+ color,
1085+ orient,
1086+ } : {
1087+ id : string ;
1088+ color : string ;
1089+ orient : string ;
1090+ } ) {
1091+ return (
1092+ < marker
1093+ id = { id }
1094+ viewBox = "0 0 10 10"
1095+ refX = "9"
1096+ refY = "5"
1097+ markerWidth = "7"
1098+ markerHeight = "7"
1099+ orient = { orient }
1100+ markerUnits = "strokeWidth"
1101+ >
1102+ < path d = "M 0 0 L 10 5 L 0 10 z" fill = { color } />
1103+ </ marker >
1104+ ) ;
1105+ }
1106+
10171107function TableView ( { el } : { el : TableElement } ) {
10181108 const cols = el . rows [ 0 ] ?. length ?? 1 ;
10191109 const rowCount = el . rows . length ;
@@ -1315,110 +1405,6 @@ function ChartView({ el }: { el: ChartElement }) {
13151405 return < div ref = { ref } style = { { width : "100%" , height : "100%" } } /> ;
13161406}
13171407
1318- /**
1319- * Translate a Slidewise ChartElement into an ECharts option object. Handles
1320- * bar / column / line / area / pie / doughnut, including stacked + percent
1321- * stacked variants. Value labels are surfaced when the source deck had
1322- * `<c:showVal val="1"/>` on at least one series.
1323- */
1324- function buildChartOption ( el : ChartElement ) {
1325- const palette = el . series . map ( ( s , i ) => s . color ?? defaultPaletteColor ( i ) ) ;
1326- const isPercent = el . grouping === "percentStacked" ;
1327- const valueFormatter = makeValueFormatter ( el . valueFormat , isPercent ) ;
1328-
1329- if ( el . kind === "pie" || el . kind === "doughnut" ) {
1330- const data = el . categories . map ( ( cat , i ) => ( {
1331- name : cat || `Slice ${ i + 1 } ` ,
1332- value : el . series [ 0 ] ?. values [ i ] ?? 0 ,
1333- } ) ) ;
1334- return {
1335- color : palette ,
1336- title : el . title ? { text : el . title , left : "center" , top : 4 } : undefined ,
1337- tooltip : { trigger : "item" , valueFormatter } ,
1338- legend : { bottom : 4 } ,
1339- series : [
1340- {
1341- type : "pie" ,
1342- radius : el . kind === "doughnut" ? [ "45%" , "75%" ] : "70%" ,
1343- data,
1344- label : el . showDataLabels
1345- ? { formatter : ( p : { value : number } ) => valueFormatter ( p . value ) }
1346- : { show : false } ,
1347- } ,
1348- ] ,
1349- } ;
1350- }
1351-
1352- const isHorizontal = el . kind === "bar" ; // "column" + everything else: vertical
1353- const xAxis = isHorizontal
1354- ? { type : "value" , axisLabel : { formatter : valueFormatter } }
1355- : { type : "category" , data : el . categories } ;
1356- const yAxis = isHorizontal
1357- ? { type : "category" , data : el . categories }
1358- : { type : "value" , axisLabel : { formatter : valueFormatter } } ;
1359-
1360- const stackKey =
1361- el . grouping === "stacked" || el . grouping === "percentStacked"
1362- ? "total"
1363- : undefined ;
1364-
1365- const series = el . series . map ( ( s , i ) => {
1366- const color = s . color ?? defaultPaletteColor ( i ) ;
1367- const base = {
1368- name : s . name ,
1369- type : el . kind === "line" ? "line" : el . kind === "area" ? "line" : "bar" ,
1370- data : s . values . map ( ( v ) => ( v === null ? 0 : v ) ) ,
1371- // Pin the colour explicitly so ECharts can't reassign via palette
1372- // cycling when multiple series share the same `name` (PowerPoint
1373- // decks routinely do this — same label, distinct colour fills).
1374- itemStyle : { color } ,
1375- ...( el . kind === "area" ? { areaStyle : { color } } : { } ) ,
1376- ...( el . kind === "line"
1377- ? { lineStyle : { color } , symbol : "circle" , symbolSize : 6 }
1378- : { } ) ,
1379- ...( stackKey ? { stack : stackKey } : { } ) ,
1380- label : el . showDataLabels
1381- ? {
1382- show : true ,
1383- position : stackKey ? "inside" : "top" ,
1384- formatter : ( p : { value : number } ) => valueFormatter ( p . value ) ,
1385- fontSize : 11 ,
1386- color : stackKey ? "#FFFFFF" : "#111111" ,
1387- }
1388- : { show : false } ,
1389- } ;
1390- return base ;
1391- } ) ;
1392-
1393- return {
1394- color : palette ,
1395- title : el . title ? { text : el . title , left : "center" , top : 4 } : undefined ,
1396- tooltip : { trigger : "axis" , valueFormatter } ,
1397- legend : { bottom : 4 , type : "scroll" } ,
1398- grid : { left : 56 , right : 24 , top : el . title ? 36 : 16 , bottom : 56 } ,
1399- xAxis,
1400- yAxis,
1401- series,
1402- } ;
1403- }
1404-
1405- function defaultPaletteColor ( i : number ) : string {
1406- // Office-ish accent rotation, used when a series omits explicit fill.
1407- const palette = [ "#4F81BD" , "#C0504D" , "#9BBB59" , "#8064A2" , "#4BACC6" , "#F79646" ] ;
1408- return palette [ i % palette . length ] ;
1409- }
1410-
1411- function makeValueFormatter ( formatCode : string | undefined , percent : boolean ) {
1412- return ( value : number ) => {
1413- if ( typeof value !== "number" || ! Number . isFinite ( value ) ) return "" ;
1414- if ( percent ) return `${ Math . round ( value * 100 ) } %` ;
1415- if ( formatCode && formatCode . includes ( "$" ) ) {
1416- const decimals = ( formatCode . match ( / 0 \. ( 0 + ) / ) ?. [ 1 ] ?? "" ) . length ;
1417- return `$${ value . toLocaleString ( undefined , {
1418- minimumFractionDigits : decimals ,
1419- maximumFractionDigits : decimals ,
1420- } ) } `;
1421- }
1422- return value . toLocaleString ( ) ;
1423- } ;
1424- }
1408+ // Chart-option construction (buildChartOption / defaultPaletteColor /
1409+ // makeValueFormatter) lives in @/lib/chart/chartOption so it can be shared with
1410+ // hosts via the public API for server-side previews. ChartView imports it.
0 commit comments