From 9046f3c27e707c430e9e823e074f8a0f2e18ae1f Mon Sep 17 00:00:00 2001 From: Ketaki Deshwandikar Date: Mon, 24 Aug 2026 20:11:29 +0530 Subject: [PATCH] ai-mapping-chart --- streamlibs/blocks/chart.js | 103 ++++++++++++++++++ .../operations/annotation/annotation.css | 13 +++ streamlibs/sources/figma.js | 36 ++++++ streamlibs/utils/block-action-button.js | 23 +++- 4 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 streamlibs/blocks/chart.js diff --git a/streamlibs/blocks/chart.js b/streamlibs/blocks/chart.js new file mode 100644 index 00000000..9eb70ce3 --- /dev/null +++ b/streamlibs/blocks/chart.js @@ -0,0 +1,103 @@ +import { handleComponents } from '../components/components.js'; +import { safeJsonFetch } from '../utils/error-handler.js'; + +const CHART_TYPES = ['area', 'bar', 'column', 'donut', 'line', 'list', 'oversized-number', 'pie']; +const CHART_COLORS = ['blue', 'indigo', 'purple', 'magenta', 'seafoam', 'green', 'orange']; + +// Ensure the block carries exactly the extracted chart-type class. The template +// block already ships with the right type (variant selection), but the Figma +// `chartType` is authoritative, so normalise here. +function handleChartType(blockContent, chartType) { + if (!chartType || !CHART_TYPES.includes(chartType)) return; + blockContent.classList.add(chartType); +} + +function handleColor(blockContent, color) { + if (color && CHART_COLORS.includes(color)) blockContent.classList.add(color); +} + +function handleLabelDirection(blockContent, direction) { + if (direction === 'diagonal') blockContent.classList.add('diagonal'); +} + +function handleBackground(sectionWrapper, background) { + if (!background) return; + const sectionMetadata = document.createElement('div'); + sectionMetadata.classList.add('section-metadata'); + const row = document.createElement('div'); + const keyDiv = document.createElement('div'); + keyDiv.textContent = 'Background'; + const valueDiv = document.createElement('div'); + valueDiv.textContent = background; + row.appendChild(keyDiv); + row.appendChild(valueDiv); + sectionMetadata.appendChild(row); + sectionWrapper.appendChild(sectionMetadata); +} + +// The data-source row holds a link to the external chart JSON. Keep the row in +// place (Milo reads chart rows positionally: title > subtitle > data > footnote). +// When Figma has no real JSON (PM supplies it later) leave the template link so +// the author can replace it. +const MILO_DOC_ORIGIN = 'https://main--milo--adobecom.aem.page'; + +function handleDataSource(blockContent, selector, value) { + const linkEl = blockContent.querySelector(selector); + if (!linkEl) return; + if (value) { + linkEl.href = value; + linkEl.textContent = value; + return; + } + + const raw = linkEl.getAttribute('href') || ''; + if (raw.startsWith('/')) { + const abs = `${MILO_DOC_ORIGIN}${raw}`; + linkEl.setAttribute('href', abs); + linkEl.textContent = abs; + } else if (raw.includes('--milo--adobecom.hlx.page')) { + const abs = raw.replace('--milo--adobecom.hlx.page', '--milo--adobecom.aem.page'); + linkEl.setAttribute('href', abs); + linkEl.textContent = abs; + } +} + +export default async function mapBlockContent( + sectionWrapper, + blockContent, + figContent, +) { + const properties = figContent?.details?.properties; + if (!properties) return; + + try { + // Strip all template-inherited classes (kitchen-sink colors, border, diagonal-label + // variants, etc.) so only Figma-driven classes end up on the block. + blockContent.className = 'chart'; + + const mappingData = await safeJsonFetch('chart.json'); + mappingData.data.forEach((mappingConfig) => { + const value = properties[mappingConfig.key]; + switch (mappingConfig.key) { + case 'dataSource': + handleDataSource(blockContent, mappingConfig.selector, value); + break; + default: + if (value) handleComponents(blockContent, value, mappingConfig); + break; + } + }); + // Drop emptied text rows' inner cells (placeholder + to-remove); the outer + // row div stays so Milo's positional row parsing is preserved. + blockContent.querySelectorAll('.to-remove').forEach((el) => el.remove()); + + handleChartType(blockContent, properties.chartType); + handleColor(blockContent, properties.color); + handleLabelDirection(blockContent, properties.labelDirection); + if (properties.colorTheme === 'dark') blockContent.classList.add('dark'); + handleBackground(sectionWrapper, properties.background); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } +} diff --git a/streamlibs/operations/annotation/annotation.css b/streamlibs/operations/annotation/annotation.css index 78e3d74e..aabf0643 100644 --- a/streamlibs/operations/annotation/annotation.css +++ b/streamlibs/operations/annotation/annotation.css @@ -1993,6 +1993,13 @@ body.annotation-asset-select-mode main img:hover { isolation: isolate; } +/* Chart blocks: push footnote text away from the button's bottom-right corner. + Scoped to `.chart.has-block-action` so only the chart that carries the button is + affected — non-chart blocks and charts without a button are untouched. */ +.chart.has-block-action .footnote { + padding-right: 40px; +} + .block-action-btn { position: absolute; bottom: 0; @@ -2031,6 +2038,12 @@ body.editor-mode .figma-panel .has-block-action .block-action-btn { 0 6px 18px rgba(15, 23, 42, 0.2); } +/* Editor mode: button is 72px wide + 10px inset, so footnote needs more clearance. */ +body.editor-mode .da-panel .chart.has-block-action .footnote, +body.editor-mode .figma-panel .chart.has-block-action .footnote { + padding-right: 90px; +} + body.editor-mode .da-panel .has-block-action .block-action-btn .block-action-btn-icon, body.editor-mode .figma-panel .has-block-action .block-action-btn .block-action-btn-icon { width: 40px; diff --git a/streamlibs/sources/figma.js b/streamlibs/sources/figma.js index 0dc5079b..e714a67f 100644 --- a/streamlibs/sources/figma.js +++ b/streamlibs/sources/figma.js @@ -279,6 +279,41 @@ function showFailedBlocksPopover(failedBlocks) { }); } +// Merge charts that belong to the same Chart Selector (2-up/3-up) into a single +// section wrapper so Milo counts N chart divs and applies up-N (side-by-side), +// matching the Figma layout. Charts are moved left→right by chartGroupOrder into +// the group's earliest wrapper (preserving vertical page position); the other +// now-empty wrappers are dropped (set to null → removed by the caller's filter). +// blocks and htmlParts are index-aligned; blocks carry chartGroupId/chartGroupOrder +// from stream-service. Blocks with no chartGroupId (all non-chart blocks and +// standalone 1-up charts) are skipped entirely, so nothing else is affected. +function mergeChartGroups(blocks, htmlParts) { + const groups = new Map(); + blocks.forEach((block, i) => { + const groupId = block?.chartGroupId; + if (!groupId) return; + const part = htmlParts[i]; + // Only merge fully-mapped chart section wrappers (Element hosting a .chart). + if (!(part instanceof Element) || !part.querySelector(':scope > .chart')) return; + if (!groups.has(groupId)) groups.set(groupId, []); + groups.get(groupId).push({ index: i, order: block.chartGroupOrder ?? 0, part }); + }); + + groups.forEach((members) => { + if (members.length < 2) return; + const hostIndex = Math.min(...members.map((m) => m.index)); + const host = members.find((m) => m.index === hostIndex).part; + // Re-append every member's chart into the host in left→right order. + // appendChild moves nodes, so the host's own chart is reordered in place too. + [...members] + .sort((a, b) => a.order - b.order) + .map((m) => m.part.querySelector(':scope > .chart')) + .filter(Boolean) + .forEach((chartEl) => host.appendChild(chartEl)); + members.forEach((m) => { if (m.part !== host) htmlParts[m.index] = null; }); + }); +} + async function createHTML(blockMapping, figmaUrl, tracker) { const blocks = blockMapping.details.components; const { blockContentConcurrency } = await getFigmaRetryConfig(); @@ -289,6 +324,7 @@ async function createHTML(blockMapping, figmaUrl, tracker) { ); const failedBlocks = htmlParts.filter((r) => r?._failed); if (failedBlocks.length) showFailedBlocksPopover(failedBlocks); + mergeChartGroups(blocks, htmlParts); return htmlParts.filter((r) => r && !r._failed); } diff --git a/streamlibs/utils/block-action-button.js b/streamlibs/utils/block-action-button.js index 3e070d69..36f0a77e 100644 --- a/streamlibs/utils/block-action-button.js +++ b/streamlibs/utils/block-action-button.js @@ -1,7 +1,26 @@ /** Shared "create fragment" control for Figma and DA block roots in the preview. */ export function appendBlockActionButton(blockEl) { if (!blockEl || typeof blockEl.classList === 'undefined') return; - blockEl.classList.add('has-block-action'); + // Chart-only guard: Milo's chart.js sizes each chart by counting its section's + // child divs (`:scope > div:not(.section-metadata)`). Appending the control to + // the section wrapper makes Milo's decorateDefaults wrap the (non-div) button in + // a `div.content`, so the count becomes 2 → the section gets `up-2` and the chart + // is pinned to `calc(50% - 8px)` wide. Host the control INSIDE the chart block + // instead so it is never a section-level sibling and the count stays correct. + // + // For merged 2-up/3-up groups, charts are already sorted left→right by + // mergeChartGroups, so the LAST `.chart` child is the rightmost — its right edge + // IS the group's right edge. Hosting the button there places it at the bottom-right + // corner of the whole group without affecting Milo's up-N count. + const chartEls = blockEl.querySelectorAll ? [...blockEl.querySelectorAll(':scope > .chart')] : []; + let host; + if (chartEls.length > 1) { + host = chartEls[chartEls.length - 1]; + host.classList.add('chart-group-end'); + } else { + host = chartEls[0] || blockEl; + } + host.classList.add('has-block-action'); const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'block-action-btn'; @@ -24,7 +43,7 @@ export function appendBlockActionButton(blockEl) { `; btn.appendChild(icon); - blockEl.appendChild(btn); + host.appendChild(btn); } /**