diff --git a/Samples/DataApp/README.md b/Samples/DataApp/README.md new file mode 100644 index 00000000..38efa8f1 --- /dev/null +++ b/Samples/DataApp/README.md @@ -0,0 +1,21 @@ +# Tableau Superstore Data App + +Tableau's Superstore sample workbook, rebuilt as a live-query Tableau data app in plain JavaScript with no build step. + +Tableau data apps let you build a dashboard as a web app that runs inside a Tableau workbook and queries its data live. This sample has five tabs that query the Superstore sample data through the Tableau Extensions API and VizQL Data Service: + +- **Overview:** KPIs, a US choropleth map, monthly sales/profit panels, and selection-driven scope. +- **Product:** a Category × Year heatmap and a Sub-Category × Segment scatter. +- **Customers:** a region summary, a customer scatter, and a ranked customer list. +- **Shipping:** an Early / On Time / Late classification, derived client-side from Order and Ship dates against a per-Ship-Mode expected-transit table. +- **Performance:** Sales vs. period-average bar-in-bar small multiples and a ranked Sub-Category view colored by profit ratio. + +The Region and Order Date filters scope every tab. Requires Tableau 2026.3.1 or later. + +## The data ships in the workbook + +The workbook's datasource is `Tableau Superstore Data App/Data/Datasources/Sample - Superstore.xls`, the Superstore sample that Tableau installs in My Tableau Repository, referenced by relative path, so no datasource needs to exist on your site. + +## Publish to Tableau Cloud + +Use the [Tableau plugin](https://github.com/tableau/tableau-plugin), which brings the [Tableau MCP](https://github.com/tableau/tableau-mcp) and its data-app skill, in Codex or Claude Code. diff --git a/Samples/DataApp/Tableau Superstore Data App/Data/Datasources/Sample - Superstore.xls b/Samples/DataApp/Tableau Superstore Data App/Data/Datasources/Sample - Superstore.xls new file mode 100644 index 00000000..7f294f45 Binary files /dev/null and b/Samples/DataApp/Tableau Superstore Data App/Data/Datasources/Sample - Superstore.xls differ diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/index.html b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/index.html new file mode 100644 index 00000000..0f1c083e --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/index.html @@ -0,0 +1,49 @@ + + + + + + Tableau Superstore Data App + + + + + + + + + +

Loading Tableau Superstore Data App…

+ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/boot.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/boot.js new file mode 100644 index 00000000..eaa1227e --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/boot.js @@ -0,0 +1,21 @@ +// Loaded first, before every other app script. The extension sandbox has no +// visible console, so any error thrown while the app's scripts load or boot is +// written over the #root placeholder instead of leaving a blank sheet. Once the +// app has mounted (.viz-root exists) it reports its own errors. +(function () { + 'use strict'; + window.SV = window.SV || {}; + function showFatal(err) { + var root = document.getElementById('root'); + if (!root || root.querySelector('.viz-root')) return; + var p = document.createElement('p'); + p.className = 'error boot'; + p.textContent = + 'Data app failed to start: ' + ((err && (err.message || err.errorCode)) || String(err)); + root.replaceChildren(p); + } + window.SV.showFatal = showFatal; + window.addEventListener('error', function (e) { + showFatal(e.error || e.message); + }); +})(); diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/App.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/App.js new file mode 100644 index 00000000..e592b6a2 --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/App.js @@ -0,0 +1,348 @@ +(function (SV) { + 'use strict'; + var { + CustomersView, + OverviewView, + PerformanceView, + ProductView, + RangeSlider, + RegionFilter, + SelectionChip, + ShippingView, + clear, + createTooltip, + fmtDateUS, + fmtPct, + h, + loadDateRange, + } = SV; + const errMsg = (err) => (err && (err.message || err.errorCode)) || String(err); + + // In-app tabs (NOT Tableau workbook sheets) mirroring the reference Superstore + // workbook's views. 'overview' owns the shell's Region/date/selection state + the + // map; the rest are self-contained view factories fed the shared { ds, region, + // dateSel } scope and own their own live queries. Each factory returns + // { el, update({region,dateSel}), destroy() }. + const TABS = [ + { id: 'overview', label: 'Overview', title: 'Executive Overview — Profitability' }, + { id: 'product', label: 'Product', title: 'Product Drilldown', make: ProductView }, + { id: 'customers', label: 'Customers', title: 'Customer Analysis', make: CustomersView }, + { id: 'shipping', label: 'Shipping', title: 'Shipping Overview', make: ShippingView }, + { id: 'performance', label: 'Performance', title: 'Performance', make: PerformanceView }, + ]; + + // Profit Ratio slider domain (matches the map legend's ±50%); the ends stay + // "open" so states beyond ±50% aren't hidden at full range. 1% step. + const RATIO_MIN = -0.5, + RATIO_MAX = 0.5, + RATIO_STEP = 0.01; + const DAY = 86400000; + + // 'YYYY-MM-DD' <-> timestamp, anchored at local noon so day math and the + // M/D/YYYY labels round-trip without a timezone shift. + function isoToTs(iso) { + const [y, m, d] = iso.split('-').map(Number); + return new Date(y, m - 1, d, 12).getTime(); + } + function tsToIso(t) { + const d = new Date(t); + const p = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; + } + + // Datasources live on the workbook (dashboard/viz extension) or, failing that, + // on the current worksheet's content, so try both. + function getDataSources() { + const wb = window.tableau.extensions.workbook; + if (wb && typeof wb.getAllDataSourcesAsync === 'function') return wb.getAllDataSourcesAsync(); + const wc = window.tableau.extensions.worksheetContent; + if (wc && wc.worksheet && typeof wc.worksheet.getDataSourcesAsync === 'function') { + return wc.worksheet.getDataSourcesAsync(); + } + return Promise.resolve([]); + } + + // ============================================================================ + // Superstore Data App — shell (vanilla entry). + // + // A dependency-free reproduction of the classic Superstore dashboards, rebuilt + // as a Tableau viz (worksheet) extension. It carries NO embedded data: every + // number is queried live from the wired published datasource through the + // Extensions API (ds.queryAsync → VizQL Data Service). A Region filter and an + // Order Date range scope the whole app; on the Overview tab, selecting states on + // the map scopes the two monthly panels and a Profit Ratio slider filters the + // map client-side. + // + // App owns the shell, boot, and orchestration; each tab is a self-contained view + // factory. Region/Date changes call the active view's update(); the Overview view + // additionally exposes setSelection()/setRatio() for the map interactions. All + // untrusted text (state/segment/category names) flows through DOM text/attributes + // via the h()/svg() builders — never innerHTML. + // ============================================================================ + function createApp() { + let ds = null; + let tab = 'overview'; + let region = null; // null = All + let selected = []; // array of state names (Overview map selection) + let ratioSel = [RATIO_MIN, RATIO_MAX]; + let dateDomain = null; // { minT, maxT, minIso, maxIso } + let dateSel = null; // { minDate, maxDate } | null (full range) + let activeView = null; + let busyCount = 0; + + const vizRoot = h('div', { class: 'viz-root' }); + const tip = createTooltip(vizRoot); + + // Render-stable viz context handed to every view (hover never rebuilds trees). + const viz = { + showTip: (content, x, y) => tip.show(content, x, y), + hideTip: () => tip.hide(), + vizRootEl: vizRoot, + beginLoad() { + busyCount++; + vizRoot.classList.add('is-busy'); + }, + endLoad() { + busyCount = Math.max(0, busyCount - 1); + if (!busyCount) vizRoot.classList.remove('is-busy'); + }, + }; + + // ---- header -------------------------------------------------------------- + const titleEl = h('h1', { class: 'title' }); + const tagEl = h('span', { class: 'title-tag' }); + + function onRegionChange(r) { + region = r; + tagEl.textContent = region || 'All'; + if (activeView) activeView.update({ region, dateSel }); + } + const regionEl = RegionFilter(region, onRegionChange); + + const dateSlot = h('div', { style: { display: 'contents' } }); // filled once domain loads + + // Profit Ratio slider + selection chip — Overview only, kept as persistent + // nodes so their state survives tab switches. + const ratioSlider = RangeSlider({ + min: RATIO_MIN, + max: RATIO_MAX, + step: RATIO_STEP, + value: ratioSel, + onInput: onRatio, + onCommit: onRatio, + format: (v) => fmtPct(v, 0), + label: 'Profit ratio', + }); + const ratioFilterEl = h( + 'div', + { class: 'filter filter-range' }, + h('span', { class: 'filter-label' }, 'Profit Ratio'), + ratioSlider.el, + ); + const chipHolder = h('span', { style: { display: 'contents' } }); + + function onRatio(next) { + ratioSel = next; + if (activeView && tab === 'overview' && activeView.setRatio) activeView.setRatio(ratioSel); + } + function renderChip() { + clear(chipHolder); + const c = SelectionChip(selected, clearSelection); + if (c) chipHolder.appendChild(c); + } + + const filtersEl = h('div', { class: 'filters' }, regionEl, dateSlot); + + function setOverviewFilters(on) { + const present = ratioFilterEl.parentNode === filtersEl; + if (on && !present) { + filtersEl.appendChild(ratioFilterEl); + filtersEl.appendChild(chipHolder); + } else if (!on && present) { + ratioFilterEl.remove(); + chipHolder.remove(); + } + } + + const header = h( + 'header', + { class: 'top' }, + h('div', { class: 'title-wrap' }, titleEl, tagEl), + filtersEl, + ); + + // ---- tabs ---------------------------------------------------------------- + const tabButtons = TABS.map((t) => + h( + 'button', + { + type: 'button', + role: 'tab', + class: 'tab', + onClick: () => setTab(t.id), + }, + t.label, + ), + ); + const tabsEl = h( + 'div', + { class: 'tabs', role: 'tablist', 'aria-label': 'Dashboards' }, + tabButtons, + ); + + function syncTabs() { + TABS.forEach((t, i) => { + const on = t.id === tab; + tabButtons[i].setAttribute('aria-selected', on ? 'true' : 'false'); + tabButtons[i].classList.toggle('is-active', on); + }); + } + + // ---- content host (display:contents → view cards are flex items of viz-root) + const contentHost = h('div', { style: { display: 'contents' } }); + + vizRoot.appendChild(header); + vizRoot.appendChild(tabsEl); + vizRoot.appendChild(contentHost); + // tip was appended to vizRoot by createTooltip; keep it last so it floats above. + vizRoot.appendChild(tip.el); + + // ---- Overview map interactions ------------------------------------------- + function toggleState(name) { + selected = selected.includes(name) + ? selected.filter((n) => n !== name) + : selected.concat([name]); + renderChip(); + if (activeView && tab === 'overview' && activeView.setSelection) + activeView.setSelection(selected); + } + function clearSelection() { + selected = []; + renderChip(); + if (activeView && tab === 'overview' && activeView.setSelection) + activeView.setSelection(selected); + } + + // ---- tab mounting -------------------------------------------------------- + function mountTab() { + if (activeView) { + activeView.destroy(); + activeView = null; + } + clear(contentHost); + const t = TABS.find((x) => x.id === tab) || TABS[0]; + titleEl.textContent = t.title; + if (!ds) return; // deferred until boot resolves the datasource + if (t.id === 'overview') { + activeView = OverviewView({ ds, viz, onToggleState: toggleState, selected, ratioSel }); + } else { + activeView = t.make({ ds, viz }); + } + contentHost.appendChild(activeView.el); + activeView.update({ region, dateSel }); + } + + function setTab(id) { + if (id === tab) return; + tab = id; + syncTabs(); + setOverviewFilters(id === 'overview'); + if (id === 'overview') renderChip(); + mountTab(); + } + + // ---- Order Date domain → build the range slider once --------------------- + function buildDateSlider(domain) { + dateDomain = domain; + const slider = RangeSlider({ + min: domain.minT, + max: domain.maxT, + step: DAY, + value: [domain.minT, domain.maxT], + onInput: () => {}, + onCommit: commitDate, + format: fmtDateUS, + label: 'Order date', + }); + clear(dateSlot); + dateSlot.appendChild( + h( + 'div', + { class: 'filter filter-range' }, + h('span', { class: 'filter-label' }, 'Order Date'), + slider.el, + ), + ); + } + + // Commit a dragged/keyed date range into the query scope. A range that spans + // the full domain sends no filter (null). Full-range is decided on the ISO + // strings actually sent to VDS (DST-stable), and the prior selection's identity + // is preserved so a no-op release doesn't refetch. + function commitDate(next) { + if (!dateDomain) return; + const [loT, hiT] = next; + const selMin = tsToIso(loT), + selMax = tsToIso(hiT); + const full = selMin <= dateDomain.minIso && selMax >= dateDomain.maxIso; + const sel = full ? null : { minDate: selMin, maxDate: selMax }; + const same = + (dateSel === null && sel === null) || + (dateSel && sel && dateSel.minDate === sel.minDate && dateSel.maxDate === sel.maxDate); + if (same) return; + dateSel = sel; + if (activeView) activeView.update({ region, dateSel }); + } + + // ---- boot ---------------------------------------------------------------- + function setFatal(msg) { + clear(vizRoot); + vizRoot.className = 'viz-root'; + vizRoot.appendChild( + h('p', { class: 'error', style: { padding: '1.5rem' } }, 'Live query unavailable: ' + msg), + ); + } + + function boot() { + if (!window.tableau || !window.tableau.extensions) { + setFatal('Extensions API not loaded'); + return; + } + window.tableau.extensions + .initializeAsync() + .then(() => getDataSources()) + .then((list) => { + const first = list && list.length ? list[0] : null; + if (!first) { + setFatal('no data source found in the workbook'); + return; + } + ds = first; + mountTab(); // ds now available → mount the active view + loadDateRange(ds) + .then((r) => { + if (!r) return; + buildDateSlider({ + minT: isoToTs(r.min), + maxT: isoToTs(r.max), + minIso: r.min, + maxIso: r.max, + }); + }) + .catch(() => {}); + }) + .catch((err) => setFatal(errMsg(err))); + } + + // initial paint (pre-ds): title/tag/tabs/overview filters visible, content empty + titleEl.textContent = TABS[0].title; + tagEl.textContent = region || 'All'; + syncTabs(); + setOverviewFilters(true); + renderChip(); + + return { el: vizRoot, boot }; + } + + Object.assign(SV, { createApp }); +})((window.SV = window.SV || {})); diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/ChoroplethMap.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/ChoroplethMap.js new file mode 100644 index 00000000..75f3ec29 --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/ChoroplethMap.js @@ -0,0 +1,147 @@ +(function (SV) { + 'use strict'; + var { GEO, TipRow, fmtPct, fmtUSD0, frag, h, makeRatioColor, svg } = SV; + // Profit-Ratio choropleth over pre-projected Albers-USA state paths. Each state + // is filled by the diverging ramp on its profit ratio; states with no data in + // the current scope use the no-data token. Hovering shows a tooltip; clicking or + // pressing Enter/Space toggles that state in the panel filter (onToggle). + // + // The paths are created ONCE and repainted in place (fill + class + aria) so a + // focused/clicked state keeps its focus. Hover is imperative too — only the two + // affected paths restyle, never the whole map. + function ChoroplethMap({ viz, onToggle }) { + let data = null; + let selSet = new Set(); + let hovered = null; + + if (!GEO || !GEO.states) { + return { + el: h('p', { class: 'muted' }, 'Map geometry unavailable.'), + setData() {}, + setSelection() {}, + }; + } + + const records = new Map(); // name -> { st, path } + + function classFor(name) { + const d = data ? data[name] : null; + const isSel = selSet.has(name); + return ( + 'state' + + (d ? '' : ' nodata') + + (name === hovered ? ' is-hover' : '') + + (isSel ? ' is-selected' : '') + + (selSet.size && !isSel ? ' is-dim' : '') + ); + } + + // Repaint one state's fill + class + aria against the current data/selection. + function paint(name, color) { + const rec = records.get(name); + if (!rec) return; + const d = data ? data[name] : null; + rec.path.setAttribute('class', classFor(name)); + rec.path.setAttribute('fill', d ? color(d.ratio) : 'var(--state-nodata)'); + rec.path.setAttribute( + 'aria-label', + rec.st.name + (d ? ', profit ratio ' + fmtPct(d.ratio, 1) : ', no data'), + ); + } + function paintAll() { + const color = makeRatioColor(viz.vizRootEl); + for (const name of records.keys()) paint(name, color); + } + + function over(e, st) { + const d = data ? data[st.name] : null; + const prev = hovered; + hovered = st.name; + const color = makeRatioColor(viz.vizRootEl); + if (prev && prev !== st.name) paint(prev, color); // un-hover the previous state + paint(st.name, color); + + const content = frag( + h('div', { class: 'tip-head' }, st.name), + d + ? frag( + TipRow({ + colorVar: '--series-sales', + label: 'Sales', + value: fmtUSD0.format(d.sales), + }), + TipRow({ + colorVar: '--series-profit', + label: 'Profit', + value: fmtUSD0.format(d.profit), + }), + TipRow({ label: 'Profit ratio', value: fmtPct(d.ratio, 1) }), + ) + : h('div', { class: 'tip-lbl' }, 'No sales in current scope'), + ); + // focus events carry no clientX/Y — fall back to the state's own box + const box = e.currentTarget.getBoundingClientRect(); + const cx = e.clientX != null ? e.clientX : box.left + box.width / 2; + const cy = e.clientY != null ? e.clientY : box.top; + viz.showTip(content, cx, cy); + } + function out() { + const prev = hovered; + hovered = null; + if (prev) paint(prev, makeRatioColor(viz.vizRootEl)); + viz.hideTip(); + } + + const paths = GEO.states.map((st) => { + const path = svg('path', { + d: st.d, + class: 'state nodata', + fill: 'var(--state-nodata)', + tabindex: 0, + role: 'button', + 'aria-label': st.name + ', no data', + onPointerEnter: (e) => over(e, st), + onPointerMove: (e) => over(e, st), + onPointerLeave: out, + onFocus: (e) => over(e, st), + onBlur: out, + onClick: () => onToggle(st.name), + onKeyDown: (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onToggle(st.name); + } + }, + }); + records.set(st.name, { st, path }); + return path; + }); + + const el = svg( + 'svg', + { + class: 'map-svg', + viewBox: GEO.viewBox, + preserveAspectRatio: 'xMidYMid meet', + role: 'group', + 'aria-label': 'Profit ratio by state', + }, + paths, + ); + + return { + el, + setData(d, sel) { + data = d; + selSet = new Set(sel); + paintAll(); + }, + setSelection(sel) { + selSet = new Set(sel); + paintAll(); + }, + }; + } + + Object.assign(SV, { ChoroplethMap }); +})((window.SV = window.SV || {})); diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/CustomersView.css b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/CustomersView.css new file mode 100644 index 00000000..3321d8f4 --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/CustomersView.css @@ -0,0 +1,192 @@ +/* ============================================================================ + Customers tab styles — Customers-by-Region mini bar strips, the + per-customer Sales/Profit scatter, and the scrollable Customer Ranking bar + list. Linked directly from index.html (no build). All rules are written + against the shared .viz-root role tokens declared in styles.css, so dark + mode follows for free. Every class here is prefixed `cust-` to stay + collision-safe alongside the sibling tab stylesheets. + ============================================================================ */ + +/* ---- Panel 1: Customers by Region ---------------------------------------- */ +.cust-region-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 12px; +} +.cust-region-col { + min-width: 0; +} +.cust-region-head { + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.cust-region-row { + display: flex; + align-items: center; + gap: 6px; + height: 22px; +} +.cust-region-name { + flex-shrink: 0; + width: 44px; + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.cust-region-barwrap { + flex: 1 1 auto; + min-width: 0; +} +.cust-region-bar { + display: block; + height: 10px; + min-width: 1px; + border-radius: 2px; + background: var(--series-sales); +} +.cust-region-val { + flex-shrink: 0; + font-size: 11px; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + white-space: nowrap; + text-align: right; + min-width: 44px; +} +@media (max-width: 900px) { + .cust-region-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} +@media (max-width: 560px) { + .cust-region-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +/* ---- middle row: scatter + ranking ---------------------------------------- */ +.cust-mid { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); + gap: 12px; + align-items: stretch; +} +@media (max-width: 860px) { + .cust-mid { + grid-template-columns: 1fr; + } +} +.cust-scatter-card, +.cust-rank-card { + display: flex; + flex-direction: column; + min-width: 0; +} + +/* ---- shared diverging profit-ratio legend (mirrors the map/product ones) - */ +.cust-legend { + display: inline-flex; + align-items: center; + gap: 8px; +} +.cust-legend-lbl { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; +} +.cust-legend-bar { + width: 96px; + height: 10px; + border-radius: 3px; + border: 1px solid var(--border); + background: linear-gradient(to right, var(--div-neg), var(--div-mid), var(--div-pos)); +} +.cust-legend-ticks { + display: inline-flex; + gap: 6px; + font-size: 10px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +/* ---- Panel 2: Sales and Profit by Customer (scatter) ---------------------- */ +.cust-scatter { + width: 100%; + flex: 1 1 auto; +} +.cust-scatter svg { + display: block; + width: 100%; + height: auto; +} +.cust-grid { + stroke: var(--gridline); + stroke-width: 1; +} +.cust-zero { + stroke: var(--baseline); + stroke-width: 1; + stroke-dasharray: 3 3; +} +.cust-axis-lbl { + fill: var(--text-muted); + font-size: 10px; + font-variant-numeric: tabular-nums; + font-family: inherit; +} +.cust-axis-title { + fill: var(--text-muted); + font-size: 10px; + font-family: inherit; +} +.cust-dot { + opacity: 0.8; +} + +/* ---- Panel 3: Customer Ranking (scrollable ranked bar list) --------------- */ +.cust-rank { + flex: 1 1 auto; + min-height: 0; +} +.cust-rank-scroll { + max-height: 380px; + overflow-y: auto; +} +.cust-rank-row { + display: flex; + align-items: center; + gap: 8px; + height: 22px; + cursor: default; +} +.cust-rank-name { + flex-shrink: 0; + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.cust-rank-bar { + flex-shrink: 0; + display: block; + height: 12px; + min-width: 1px; + border-radius: 2px; +} +.cust-rank-val { + flex-shrink: 0; + font-size: 11px; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + text-align: right; +} diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/CustomersView.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/CustomersView.js new file mode 100644 index 00000000..483a8e17 --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/CustomersView.js @@ -0,0 +1,523 @@ +(function (SV) { + 'use strict'; + var { + TipRow, + clear, + dateFilters, + fmtInt, + fmtPct, + fmtUSD0, + fmtUSD2, + fmtUSDk, + frag, + getVal, + h, + makeRatioColor, + niceStep, + num, + observeWidth, + query, + regionFilters, + svg, + } = SV; + // ============================================================================ + // Customers tab — a reproduction of the Superstore "Customer Analysis" sheet. + // Three live-queried panels, sharing the Overview's Region + Order Date scope: + // 1. Customers by Region — six small horizontal-bar strips (Count of + // Customers, Sales, Quantity, Sales per Customer, Profit, Profit Ratio), + // all rows ordered by Count of Customers descending. + // 2. Sales and Profit by Customer — one dot per customer, positioned by + // SUM(Sales)/SUM(Profit) and colored by the shared diverging profit-ratio + // ramp, with a dashed zero-profit reference line. + // 3. Customer Ranking — customers ranked by Sales descending as a scrollable + // list of horizontal bars, colored by the same profit-ratio ramp. + // Data carries no embedded values; every panel queries the wired datasource + // live via ../lib/vds.js's query/getVal/num/regionFilters/dateFilters helpers. + // ============================================================================ + + const errMsg = (err) => (err && (err.message || err.errorCode)) || String(err); + + // ---- fetchers ---------------------------------------------------------------- + // Per-Region rollup: Count of Customers (COUNTD), Sales, Quantity, Profit (all + // SUM), plus Sales-per-Customer and Profit Ratio computed client-side. Rows are + // sorted by Count of Customers descending — the shared row order every one of + // the six mini bar-charts uses, matching the reference sheet. + function fetchRegionSummary(ds, region, dateSel) { + return query( + ds, + [ + { fieldCaption: 'Region' }, + { fieldCaption: 'Customer Name', function: 'COUNTD', fieldAlias: 'Customers' }, + { fieldCaption: 'Sales', function: 'SUM', fieldAlias: 'Sales' }, + { fieldCaption: 'Profit', function: 'SUM', fieldAlias: 'Profit' }, + { fieldCaption: 'Quantity', function: 'SUM', fieldAlias: 'Qty' }, + ], + regionFilters(region).concat(dateFilters(dateSel)), + ).then((rows) => { + const list = []; + rows.forEach((r) => { + const name = r.Region; + if (name == null) return; + const sales = num(getVal(r, 'Sales', 'SUM(Sales)')); + const profit = num(getVal(r, 'Profit', 'SUM(Profit)')); + const qty = num(getVal(r, 'Qty', 'SUM(Quantity)')); + const customers = num(getVal(r, 'Customers', 'COUNTD(Customer Name)')); + list.push({ + region: name, + customers, + sales, + quantity: qty, + profit, + salesPerCustomer: customers ? sales / customers : 0, + profitRatio: sales ? profit / sales : 0, + }); + }); + list.sort((a, b) => b.customers - a.customers); + return list; + }); + } + + // Per-Customer-Name rollup: SUM(Sales), SUM(Profit) → profit ratio computed + // client-side. Feeds both the scatter (unsorted) and the ranking (sorted by + // Sales desc); also returns the domain extrema the scatter needs for its axes. + function fetchCustomerAgg(ds, region, dateSel) { + return query( + ds, + [ + { fieldCaption: 'Customer Name' }, + { fieldCaption: 'Sales', function: 'SUM', fieldAlias: 'Sales' }, + { fieldCaption: 'Profit', function: 'SUM', fieldAlias: 'Profit' }, + ], + regionFilters(region).concat(dateFilters(dateSel)), + ).then((rows) => { + const pts = []; + let maxSales = 0, + maxProfit = -Infinity, + minProfit = Infinity; + rows.forEach((r) => { + const name = r['Customer Name']; + if (name == null) return; + const sales = num(getVal(r, 'Sales', 'SUM(Sales)')); + const profit = num(getVal(r, 'Profit', 'SUM(Profit)')); + pts.push({ name, sales, profit, ratio: sales ? profit / sales : 0 }); + if (sales > maxSales) maxSales = sales; + if (profit > maxProfit) maxProfit = profit; + if (profit < minProfit) minProfit = profit; + }); + if (!isFinite(maxProfit)) maxProfit = 0; + if (!isFinite(minProfit)) minProfit = 0; + const ranked = pts.slice().sort((a, b) => b.sales - a.sales); + return { pts, ranked, maxSales, maxProfit, minProfit }; + }); + } + + // ---- Panel 1: Customers by Region ------------------------------------------- + const REGION_MEASURES = [ + { key: 'customers', label: 'Count of Customers', fmt: (v) => fmtInt.format(v) }, + { key: 'sales', label: 'Sales', fmt: (v) => fmtUSD0.format(v) }, + { key: 'quantity', label: 'Quantity', fmt: (v) => fmtInt.format(v) }, + { key: 'salesPerCustomer', label: 'Sales per Customer', fmt: (v) => fmtUSD2.format(v) }, + { key: 'profit', label: 'Profit', fmt: (v) => fmtUSD0.format(v) }, + { key: 'profitRatio', label: 'Profit Ratio', fmt: (v) => fmtPct(v, 1) }, + ]; + + function RegionBars(data) { + return h( + 'div', + { class: 'cust-region-grid' }, + REGION_MEASURES.map((m) => { + const max = Math.max(1, ...data.map((d) => d[m.key])); + return h( + 'div', + { class: 'cust-region-col' }, + h('div', { class: 'cust-region-head' }, m.label), + data.map((d) => { + const pct = max > 0 ? Math.max(0, Math.min(100, (d[m.key] / max) * 100)) : 0; + return h( + 'div', + { class: 'cust-region-row' }, + h('span', { class: 'cust-region-name' }, d.region), + h( + 'span', + { class: 'cust-region-barwrap' }, + h('span', { class: 'cust-region-bar', style: { width: pct + '%' } }), + ), + h('span', { class: 'cust-region-val' }, m.fmt(d[m.key])), + ); + }), + ); + }), + ); + } + + // ---- shared diverging profit-ratio legend ------------------------------------ + function RatioLegend() { + return h( + 'div', + { class: 'cust-legend' }, + h('span', { class: 'cust-legend-lbl' }, 'Profit Ratio'), + h('span', { class: 'cust-legend-bar' }), + h('span', { class: 'cust-legend-ticks' }, h('span', null, '−50%'), h('span', null, '+50%')), + ); + } + + // ---- Panel 2: Sales and Profit by Customer (scatter) ------------------------- + const SC_H = 340; + const SC_PAD_L = 56; + const SC_PAD_R = 14; + const SC_PAD_T = 10; + const SC_PAD_B = 30; + + function CustomerScatter(data, vizRootEl, viz) { + const color = makeRatioColor(vizRootEl); + const wrap = h('div', { class: 'cust-scatter' }); + let svgEl; + + function move(e) { + if (!svgEl || !data.pts.length) return; + const box = svgEl.getBoundingClientRect(); + const vw = svgEl._w; // current viewBox width (updated each render) + const sx = ((e.clientX - box.left) / box.width) * vw; + const sy = ((e.clientY - box.top) / box.height) * SC_H; + const xOf = svgEl._xOf, + yOf = svgEl._yOf; + let best = null, + bd = Infinity; + data.pts.forEach((p) => { + const dx = xOf(p.sales) - sx, + dy = yOf(p.profit) - sy, + q = dx * dx + dy * dy; + if (q < bd) { + bd = q; + best = p; + } + }); + if (!best) return; + viz.showTip( + frag( + h('div', { class: 'tip-head' }, best.name), + TipRow({ colorVar: '--series-sales', label: 'Sales', value: fmtUSD0.format(best.sales) }), + TipRow({ + colorVar: '--series-profit', + label: 'Profit', + value: fmtUSD0.format(best.profit), + }), + TipRow({ label: 'Profit ratio', value: fmtPct(best.ratio, 1) }), + ), + e.clientX, + e.clientY, + ); + } + + function render(width) { + clear(wrap); + const w = Math.max(280, width || 720); + const plotW = w - SC_PAD_L - SC_PAD_R; + const plotH = SC_H - SC_PAD_T - SC_PAD_B; + + const stepX = niceStep(data.maxSales, 6) || 1000; + let xMax = Math.ceil(data.maxSales / stepX) * stepX; + if (xMax <= 0) xMax = stepX; + + const stepY = niceStep(Math.max(data.maxProfit, -data.minProfit, 1), 5) || 500; + const yMaxRaw = Math.max(data.maxProfit, 0); + let yMax = Math.ceil(yMaxRaw / stepY) * stepY; + if (yMax <= 0) yMax = stepY; + const yMinRaw = Math.min(data.minProfit, 0); + const yMin = yMinRaw < 0 ? Math.floor(yMinRaw / stepY) * stepY : 0; + const ySpan = Math.max(stepY, yMax - yMin); + + const xOf = (v) => SC_PAD_L + (Math.min(v, xMax) / xMax) * plotW; + const yOf = (v) => + SC_PAD_T + plotH - ((Math.max(yMin, Math.min(v, yMax)) - yMin) / ySpan) * plotH; + + const xTicks = []; + for (let v = 0; v <= xMax + 0.5; v += stepX) xTicks.push(v); + const yTicks = []; + for (let v = yMin; v <= yMax + 0.5; v += stepY) yTicks.push(v); + + svgEl = svg( + 'svg', + { + width: w, + height: SC_H, + viewBox: `0 0 ${w} ${SC_H}`, + onPointerMove: move, + onPointerLeave: () => viz.hideTip(), + }, + yTicks.map((v) => + svg( + 'g', + null, + svg('line', { + class: 'cust-grid', + x1: SC_PAD_L, + x2: SC_PAD_L + plotW, + y1: yOf(v), + y2: yOf(v), + }), + svg( + 'text', + { class: 'cust-axis-lbl', x: SC_PAD_L - 6, y: yOf(v) + 3, 'text-anchor': 'end' }, + v === 0 ? '$0' : fmtUSDk(v), + ), + ), + ), + xTicks.map((v) => + svg( + 'g', + null, + svg('line', { + class: 'cust-grid', + x1: xOf(v), + x2: xOf(v), + y1: SC_PAD_T, + y2: SC_PAD_T + plotH, + }), + svg( + 'text', + { + class: 'cust-axis-lbl', + x: xOf(v), + y: SC_PAD_T + plotH + 16, + 'text-anchor': 'middle', + }, + v === 0 ? '$0' : fmtUSDk(v), + ), + ), + ), + svg('line', { + class: 'cust-zero', + x1: SC_PAD_L, + x2: SC_PAD_L + plotW, + y1: yOf(0), + y2: yOf(0), + }), + svg('line', { + class: 'cust-zero', + x1: xOf(0), + x2: xOf(0), + y1: SC_PAD_T, + y2: SC_PAD_T + plotH, + }), + data.pts.map((p) => + svg('circle', { + class: 'cust-dot', + cx: xOf(p.sales), + cy: yOf(p.profit), + r: 3.2, + fill: color(p.ratio), + }), + ), + svg( + 'text', + { + class: 'cust-axis-title', + x: SC_PAD_L + plotW / 2, + y: SC_H - 4, + 'text-anchor': 'middle', + }, + 'Sales', + ), + svg( + 'text', + { + class: 'cust-axis-title', + transform: `translate(14 ${SC_PAD_T + plotH / 2}) rotate(-90)`, + 'text-anchor': 'middle', + }, + 'Profit', + ), + ); + // stash geometry the pointer handler needs (recomputed each render) + svgEl._w = w; + svgEl._xOf = xOf; + svgEl._yOf = yOf; + wrap.appendChild(svgEl); + } + + const disconnect = observeWidth(wrap, render); + return { el: wrap, disconnect }; + } + + // ---- Panel 3: Customer Ranking (scrollable ranked bar list) ----------------- + const RANK_NAME_W = 132; + const RANK_VAL_W = 74; + + function CustomerRanking(data, vizRootEl, viz) { + const color = makeRatioColor(vizRootEl); + const maxSales = data.ranked.length ? data.ranked[0].sales : 0; + const wrap = h('div', { class: 'cust-rank' }); + + function over(e, p) { + viz.showTip( + frag( + h('div', { class: 'tip-head' }, p.name), + TipRow({ colorVar: '--series-sales', label: 'Sales', value: fmtUSD0.format(p.sales) }), + TipRow({ colorVar: '--series-profit', label: 'Profit', value: fmtUSD0.format(p.profit) }), + TipRow({ label: 'Profit ratio', value: fmtPct(p.ratio, 1) }), + ), + e.clientX, + e.clientY, + ); + } + + function render(width) { + clear(wrap); + const barAvail = Math.max(60, (width || 420) - RANK_NAME_W - RANK_VAL_W - 20); + wrap.appendChild( + h( + 'div', + { class: 'cust-rank-scroll' }, + data.ranked.map((p) => { + const barPx = + maxSales > 0 ? Math.max(1, (Math.max(0, p.sales) / maxSales) * barAvail) : 0; + return h( + 'div', + { + class: 'cust-rank-row', + onPointerEnter: (e) => over(e, p), + onPointerMove: (e) => over(e, p), + onPointerLeave: () => viz.hideTip(), + }, + h('span', { class: 'cust-rank-name', style: { width: RANK_NAME_W + 'px' } }, p.name), + h('span', { + class: 'cust-rank-bar', + style: { width: barPx + 'px', background: color(p.ratio) }, + }), + h( + 'span', + { class: 'cust-rank-val', style: { width: RANK_VAL_W + 'px' } }, + fmtUSD0.format(p.sales), + ), + ); + }), + ), + ); + } + + const disconnect = observeWidth(wrap, render); + return { el: wrap, disconnect }; + } + + const muted = (t) => h('p', { class: 'muted' }, t); + const errP = (t) => h('p', { class: 'error' }, t); + function setBody(slot, node) { + clear(slot); + slot.appendChild(node); + } + + // ---- top-level view ----------------------------------------------------------- + function CustomersView({ ds, viz }) { + const vizRootEl = viz.vizRootEl; + let region = null, + dateSel = null; + let token = 0; + let scatterDc = null, + rankDc = null; + + const regionSlot = h('div', null, muted('Loading…')); + const scatterSlot = h('div', null, muted('Loading…')); + const rankSlot = h('div', null, muted('Loading…')); + + const el = h( + 'div', + { style: { display: 'contents' } }, + h( + 'section', + { class: 'card' }, + h('h2', { class: 'card-title' }, 'Customers by Region'), + regionSlot, + ), + h( + 'div', + { class: 'cust-mid' }, + h( + 'section', + { class: 'card cust-scatter-card' }, + h( + 'div', + { class: 'panel-head' }, + h('h2', { class: 'card-title' }, 'Sales and Profit by Customer'), + RatioLegend(), + ), + scatterSlot, + ), + h( + 'section', + { class: 'card cust-rank-card' }, + h('h2', { class: 'card-title' }, 'Customer Ranking'), + rankSlot, + ), + ), + ); + + function clearScatter() { + if (scatterDc) { + scatterDc(); + scatterDc = null; + } + } + function clearRank() { + if (rankDc) { + rankDc(); + rankDc = null; + } + } + + function load() { + const my = ++token; + viz.beginLoad(); + const p1p = fetchRegionSummary(ds, region, dateSel) + .then((d) => { + if (my !== token) return; + setBody(regionSlot, d.length ? RegionBars(d) : muted('No data in current scope.')); + }) + .catch((e) => { + if (my === token) + setBody(regionSlot, errP('Could not load region summary: ' + errMsg(e))); + }); + const p2p = fetchCustomerAgg(ds, region, dateSel) + .then((d) => { + if (my !== token) return; + clearScatter(); + clearRank(); + if (d.pts.length) { + const sc = CustomerScatter(d, vizRootEl, viz); + scatterDc = sc.disconnect; + setBody(scatterSlot, sc.el); + } else setBody(scatterSlot, muted('No customers in current scope.')); + if (d.ranked.length) { + const rk = CustomerRanking(d, vizRootEl, viz); + rankDc = rk.disconnect; + setBody(rankSlot, rk.el); + } else setBody(rankSlot, muted('No customers in current scope.')); + }) + .catch((e) => { + if (my !== token) return; + clearScatter(); + clearRank(); + const msg = 'Could not load customers: ' + errMsg(e); + setBody(scatterSlot, errP(msg)); + setBody(rankSlot, errP(msg)); + }); + Promise.all([p1p, p2p]).then(() => viz.endLoad()); + } + + return { + el, + update({ region: r, dateSel: d }) { + region = r ?? null; + dateSel = d ?? null; + load(); + }, + destroy() { + token++; + clearScatter(); + clearRank(); + }, + }; + } + + Object.assign(SV, { CustomersView }); +})((window.SV = window.SV || {})); diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/KpiRow.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/KpiRow.js new file mode 100644 index 00000000..f2abe5ef --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/KpiRow.js @@ -0,0 +1,48 @@ +(function (SV) { + 'use strict'; + var { fmtInt, fmtPct, fmtUSD0, fmtUSD2, h } = SV; + // Seven headline KPIs, in fixed order. Before data arrives (or when a value is + // missing) each tile shows an em dash rather than an empty box. + const LABELS = [ + 'Sales', + 'Profit', + 'Profit Ratio', + 'Profit per Order', + 'Sales per Customer', + 'Avg. Discount', + 'Quantity', + ]; + + function values(k) { + return [ + fmtUSD0.format(k.sales), + fmtUSD0.format(k.profit), + fmtPct(k.profitRatio, 1), + fmtUSD2.format(k.profitPerOrder), + fmtUSD2.format(k.salesPerCustomer), + fmtPct(k.discount, 2), + fmtInt.format(k.quantity), + ]; + } + + // Returns the .kpis grid, or a .error paragraph on failure. Callers swap the + // whole node when data changes. + function KpiRow(kpis, error) { + if (error) return h('p', { class: 'error' }, 'Could not load KPIs: ' + error); + const vals = kpis ? values(kpis) : LABELS.map(() => '—'); + return h( + 'div', + { class: 'kpis' }, + LABELS.map((label, i) => + h( + 'div', + { class: 'kpi' }, + h('div', { class: 'kpi-label' }, label), + h('div', { class: 'kpi-value' }, vals[i]), + ), + ), + ); + } + + Object.assign(SV, { KpiRow }); +})((window.SV = window.SV || {})); diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/MapLegend.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/MapLegend.js new file mode 100644 index 00000000..887a0da6 --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/MapLegend.js @@ -0,0 +1,38 @@ +(function (SV) { + 'use strict'; + var { h } = SV; + // Right-rail legend for the choropleth: the diverging red→gray→blue ramp with + // ±50% ticks and a note on how the fill is computed. Static — no data in, no + // state; the ramp is a pure CSS gradient built from the palette tokens. + function MapLegend() { + return h( + 'aside', + { class: 'rail' }, + h( + 'div', + { class: 'legend' }, + h('div', { class: 'legend-title' }, 'Profit Ratio'), + h('div', { + class: 'legend-bar', + style: { + background: 'linear-gradient(to right, var(--div-neg), var(--div-mid), var(--div-pos))', + }, + }), + h( + 'div', + { class: 'legend-ticks' }, + h('span', null, '-50%'), + h('span', null, '0%'), + h('span', null, '+50%'), + ), + h( + 'p', + { class: 'legend-note' }, + 'Filled by SUM(Profit) ÷ SUM(Sales) per state. Click states to filter the monthly charts.', + ), + ), + ); + } + + Object.assign(SV, { MapLegend }); +})((window.SV = window.SV || {})); diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/MonthlyPanel.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/MonthlyPanel.js new file mode 100644 index 00000000..f74827c0 --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/MonthlyPanel.js @@ -0,0 +1,312 @@ +(function (SV) { + 'use strict'; + var { + MONTH_NAMES, + TipRow, + clear, + fmtUSD0, + fmtUSDk, + frag, + h, + niceStep, + observeWidth, + selectionLabel, + svg, + } = SV; + // Layout constants (viewBox units). + const LABEL_W = 96; + const PAD_L = 44; + const PAD_R = 10; + const FACET_H = 78; + const AXIS_H = 20; + const INNER_H = FACET_H - 8; + + // Explicit M/L path for a measure's line, plus a baseline-closed area. Separators + // between every coordinate so adjacent numbers never merge into an ambiguous one. + function seriesPath(pts, getter, xOf, yOf) { + let line = ''; + pts.forEach((p, i) => { + line += (i === 0 ? 'M' : 'L') + xOf(p.t).toFixed(1) + ' ' + yOf(getter(p)).toFixed(1) + ' '; + }); + const x0 = xOf(pts[0].t); + const xN = xOf(pts[pts.length - 1].t); + const yb = yOf(0); + let area = 'M' + x0.toFixed(1) + ' ' + yb.toFixed(1) + ' '; + pts.forEach((p) => { + area += 'L' + xOf(p.t).toFixed(1) + ' ' + yOf(getter(p)).toFixed(1) + ' '; + }); + area += 'L' + xN.toFixed(1) + ' ' + yb.toFixed(1) + ' Z'; + return { line: line.trim(), area }; + } + + // One facet row: Sales (blue) + Profit (orange) area/line over the shared domain, + // with a crosshair + dots that track the nearest month on hover. Hover is local + // and imperative — moving the pointer touches only this facet's crosshair/dots. + function Facet({ viz, facet, pts, cellW, plotW, xOf, yOf, yTicks, domainMin }) { + const sales = seriesPath(pts, (p) => p.sales, xOf, yOf); + const profit = seriesPath(pts, (p) => p.profit, xOf, yOf); + + const grid = yTicks.map((v) => { + const y = yOf(v); + const gridCls = v === 0 && domainMin < 0 ? 'grid zero' : 'grid'; + return svg( + 'g', + null, + svg('line', { x1: PAD_L, y1: y, x2: PAD_L + plotW, y2: y, class: gridCls }), + svg( + 'text', + { x: PAD_L - 6, y: y + 3, class: 'y-lbl', 'text-anchor': 'end' }, + v === 0 ? '$0' : fmtUSDk(v), + ), + ); + }); + + const crosshair = svg('line', { + class: 'crosshair', + x1: -10, + x2: -10, + y1: 4, + y2: 4 + INNER_H, + style: { display: 'none' }, + }); + const dotSales = svg('circle', { + class: 'cross-dot', + r: 3.2, + fill: 'var(--series-sales)', + cx: -10, + cy: 0, + style: { display: 'none' }, + }); + const dotProfit = svg('circle', { + class: 'cross-dot', + r: 3.2, + fill: 'var(--series-profit)', + cx: -10, + cy: 0, + style: { display: 'none' }, + }); + + let svgEl; + function nearest(clientX) { + const box = svgEl.getBoundingClientRect(); + const svgX = ((clientX - box.left) / box.width) * cellW; // client px → viewBox units + let best = pts[0]; + let bd = Infinity; + pts.forEach((p) => { + const dx = Math.abs(xOf(p.t) - svgX); + if (dx < bd) { + bd = dx; + best = p; + } + }); + return best; + } + function move(e) { + const p = nearest(e.clientX); + const cx = xOf(p.t); + crosshair.setAttribute('x1', cx); + crosshair.setAttribute('x2', cx); + crosshair.style.display = ''; + dotSales.setAttribute('cx', cx); + dotSales.setAttribute('cy', yOf(p.sales)); + dotSales.style.display = ''; + dotProfit.setAttribute('cx', cx); + dotProfit.setAttribute('cy', yOf(p.profit)); + dotProfit.style.display = ''; + const dt = new Date(p.t); + viz.showTip( + frag( + h( + 'div', + { class: 'tip-head' }, + `${facet} · ${MONTH_NAMES[dt.getMonth()]} ${dt.getFullYear()}`, + ), + TipRow({ colorVar: '--series-sales', label: 'Sales', value: fmtUSD0.format(p.sales) }), + TipRow({ colorVar: '--series-profit', label: 'Profit', value: fmtUSD0.format(p.profit) }), + ), + e.clientX, + e.clientY, + ); + } + function leave() { + crosshair.style.display = 'none'; + dotSales.style.display = 'none'; + dotProfit.style.display = 'none'; + viz.hideTip(); + } + + svgEl = svg( + 'svg', + { class: 'facet-svg', width: cellW, height: FACET_H, viewBox: `0 0 ${cellW} ${FACET_H}` }, + grid, + svg('path', { d: sales.area, class: 'area area-sales' }), + svg('path', { d: profit.area, class: 'area area-profit' }), + svg('path', { d: sales.line, class: 'line line-sales' }), + svg('path', { d: profit.line, class: 'line line-profit' }), + crosshair, + dotSales, + dotProfit, + svg('rect', { + x: PAD_L, + y: 0, + width: plotW, + height: FACET_H, + fill: 'transparent', + class: 'hit', + onPointerMove: move, + onPointerLeave: leave, + }), + ); + + return h( + 'div', + { class: 'facet' }, + h('div', { class: 'facet-label' }, facet), + h('div', { class: 'plot-cell' }, svgEl), + ); + } + + // A monthly small-multiple panel: header (title + current scope + series legend) + // and a stack of facets sharing one step-aligned y-domain (dipping below zero for + // loss months) and one year axis. Reflows on container resize. update(data, + // selectedNames) swaps the data; selectedNames is the map selection (array). + function MonthlyPanel({ viz, title }) { + let data = null; + let selectedNames = []; + + const scopeStrong = h('strong', null, 'All'); + const head = h( + 'div', + { class: 'panel-head' }, + h('h2', { class: 'card-title' }, title + ' — States/Provinces: ', scopeStrong), + h( + 'div', + { class: 'series-legend' }, + h( + 'span', + { class: 'series-item' }, + h('span', { class: 'series-swatch', style: { background: 'var(--series-sales)' } }), + h('span', null, 'Sales'), + ), + h( + 'span', + { class: 'series-item' }, + h('span', { class: 'series-swatch', style: { background: 'var(--series-profit)' } }), + h('span', null, 'Profit'), + ), + ), + ); + + const facetsEl = h('div', { class: 'facets' }); + const root = h('section', { class: 'card panel' }, head, facetsEl); + let emptyMsg = null; + + function setEmpty(on) { + if (on && !emptyMsg) { + emptyMsg = h('p', { class: 'muted' }, 'No data in current scope.'); + root.appendChild(emptyMsg); + } else if (!on && emptyMsg) { + emptyMsg.remove(); + emptyMsg = null; + } + } + + function render() { + clear(facetsEl); + if (!data || !data.order.length) { + setEmpty(true); + return; + } + setEmpty(false); + + const width = facetsEl.clientWidth; + const cellW = Math.max(160, width - LABEL_W); + const plotW = cellW - PAD_L - PAD_R; + + // step-aligned domain spanning Sales (≥0) and any negative Profit + const step = niceStep(data.maxSales, 3); + let domainMax = Math.ceil(data.maxSales / step) * step; + if (domainMax <= 0) domainMax = step; + const vMin = Math.min(0, data.minProfit || 0); + const domainMin = vMin < 0 ? Math.floor(vMin / step) * step : 0; + const domainSpan = Math.max(step, domainMax - domainMin); + const yTicks = []; + for (let v = domainMin; v <= domainMax + 0.5; v += step) yTicks.push(v); + + const span = Math.max(1, data.maxT - data.minT); + const xOf = (t) => PAD_L + ((t - data.minT) / span) * plotW; + const yOf = (val) => 4 + INNER_H - ((val - domainMin) / domainSpan) * INNER_H; + + const y0 = new Date(data.minT).getFullYear(); + const yN = new Date(data.maxT).getFullYear() + 1; + const years = []; + for (let yr = y0; yr <= yN; yr++) years.push(yr); + + data.order.forEach((facet) => { + facetsEl.appendChild( + Facet({ + viz, + facet, + pts: data.facets[facet], + cellW, + plotW, + xOf, + yOf, + yTicks, + domainMin, + }), + ); + }); + + // shared x-axis (years), aligned to the plot area + const axisTicks = years.map((yr) => { + const tx = xOf(new Date(yr, 0, 1).getTime()); + if (tx < PAD_L - 2 || tx > PAD_L + plotW + 2) return null; + return svg( + 'g', + null, + svg('line', { x1: tx, y1: 0, x2: tx, y2: 4, class: 'grid' }), + svg('text', { x: tx, y: 14, class: 'x-lbl', 'text-anchor': 'middle' }, yr), + ); + }); + facetsEl.appendChild( + h( + 'div', + { class: 'facet axis-row' }, + h('div', { class: 'facet-label' }), + h( + 'div', + { class: 'plot-cell' }, + svg( + 'svg', + { + class: 'axis-svg', + width: cellW, + height: AXIS_H, + viewBox: `0 0 ${cellW} ${AXIS_H}`, + }, + axisTicks, + ), + ), + ), + ); + } + + const disconnect = observeWidth(facetsEl, render); + + return { + el: root, + update(d, sel) { + data = d; + selectedNames = sel || []; + scopeStrong.textContent = selectedNames.length ? selectionLabel(selectedNames) : 'All'; + render(); + }, + destroy() { + disconnect(); + }, + }; + } + + Object.assign(SV, { MonthlyPanel }); +})((window.SV = window.SV || {})); diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/OverviewView.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/OverviewView.js new file mode 100644 index 00000000..621a6878 --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/OverviewView.js @@ -0,0 +1,188 @@ +(function (SV) { + 'use strict'; + var { + CATEGORIES, + ChoroplethMap, + KpiRow, + MapLegend, + MonthlyPanel, + SEGMENTS, + clear, + fetchKpis, + fetchMap, + fetchMonthly, + h, + } = SV; + const errMsg = (err) => (err && (err.message || err.errorCode)) || String(err); + + // Profit Ratio filter domain (matches the map legend's ±50%); the ends stay + // "open" so states beyond ±50% aren't hidden at full range. + const RATIO_MIN = -0.5, + RATIO_MAX = 0.5; + const EPS = 1e-9; + + // Client-side Profit Ratio filter over the map. Thumbs at the domain ends are + // "open" (no bound that side), so full range shows every state — including any + // beyond ±50%, which the color ramp clamps but the filter must not hide. + function visibleMap(mapData, ratioSel) { + if (!mapData) return mapData; + const [lo, hi] = ratioSel; + const loOpen = lo <= RATIO_MIN + EPS; + const hiOpen = hi >= RATIO_MAX - EPS; + if (loOpen && hiOpen) return mapData; + const out = Object.create(null); + for (const k in mapData) { + const r = mapData[k].ratio; + if ((loOpen || r >= lo) && (hiOpen || r <= hi)) out[k] = mapData[k]; + } + return out; + } + + // ============================================================================ + // Overview view — the classic "Executive Overview – Profitability" dashboard. + // Owns the KPI row, the choropleth (region/date scope), and the two monthly + // panels (region/date + map selection scope). The Profit Ratio filter narrows + // the map client-side with no refetch. Two cancel tokens: one for KPIs+map + // (region/date), one for the monthly panels + // (region/date + selection), so a slower earlier query can't overwrite a newer. + // ============================================================================ + function OverviewView({ ds, viz, onToggleState, selected: initSel, ratioSel: initRatio }) { + let region = null, + dateSel = null; + let selected = initSel ? initSel.slice() : []; + let ratioSel = initRatio ? initRatio.slice() : [RATIO_MIN, RATIO_MAX]; + let mapData = null, + mapErr = null; + let scopeToken = 0, + monthlyToken = 0; + + // KPI slot — swapped wholesale (grid ↔ error paragraph). + let kpiNode = KpiRow(null, null); + function setKpis(kpis, err) { + const n = KpiRow(kpis, err); + kpiNode.replaceWith(n); + kpiNode = n; + } + + const map = ChoroplethMap({ viz, onToggle: onToggleState }); + const mapHolder = h('div', { class: 'map-holder' }, map.el); + + const segPanel = MonthlyPanel({ viz, title: 'Monthly Sales by Segment' }); + const catPanel = MonthlyPanel({ viz, title: 'Monthly Sales by Product Category' }); + + const el = h( + 'div', + { style: { display: 'contents' } }, + kpiNode, + h( + 'div', + { class: 'mid' }, + h( + 'div', + { class: 'card map-card' }, + h('h2', { class: 'card-title' }, 'Profit Ratio by State'), + mapHolder, + ), + MapLegend(), + ), + h('div', { class: 'panels' }, segPanel.el, catPanel.el), + ); + + function renderMap() { + if (mapErr) { + clear(mapHolder); + mapHolder.appendChild(h('p', { class: 'error' }, 'Could not load map: ' + mapErr)); + return; + } + if (mapHolder.firstChild !== map.el) { + clear(mapHolder); + mapHolder.appendChild(map.el); + } + map.setData(visibleMap(mapData, ratioSel), selected); + } + + function loadScope() { + const my = ++scopeToken; + viz.beginLoad(); + Promise.all([ + fetchKpis(ds, { region, dateSel }) + .then((k) => { + if (my === scopeToken) setKpis(k, null); + }) + .catch((e) => { + if (my === scopeToken) setKpis(null, errMsg(e)); + }), + fetchMap(ds, { region, dateSel }) + .then((m) => { + if (my === scopeToken) { + mapData = m; + mapErr = null; + renderMap(); + } + }) + .catch((e) => { + if (my === scopeToken) { + mapErr = errMsg(e); + renderMap(); + } + }), + ]).then(() => { + viz.endLoad(); + }); // always balance beginLoad, even if superseded + } + + function loadMonthly() { + const my = ++monthlyToken; + viz.beginLoad(); + const scope = { region, states: selected, dateSel }; + Promise.all([ + fetchMonthly(ds, scope, 'Segment', SEGMENTS) + .then((d) => { + if (my === monthlyToken) segPanel.update(d, selected); + }) + .catch(() => { + if (my === monthlyToken) segPanel.update(null, selected); + }), + fetchMonthly(ds, scope, 'Category', CATEGORIES) + .then((d) => { + if (my === monthlyToken) catPanel.update(d, selected); + }) + .catch(() => { + if (my === monthlyToken) catPanel.update(null, selected); + }), + ]).then(() => { + viz.endLoad(); + }); // always balance beginLoad, even if superseded + } + + return { + el, + // Region / Order Date scope changed → both KPIs+map and the monthly panels refetch. + update({ region: r, dateSel: d }) { + region = r ?? null; + dateSel = d ?? null; + loadScope(); + loadMonthly(); + }, + // Map selection changed → re-mark the map + refetch only the monthly panels. + setSelection(sel) { + selected = sel; + map.setSelection(selected); + loadMonthly(); + }, + // Profit Ratio filter changed → re-filter the map client-side, no refetch. + setRatio(r) { + ratioSel = r; + renderMap(); + }, + destroy() { + scopeToken++; + monthlyToken++; + segPanel.destroy(); + catPanel.destroy(); + }, + }; + } + + Object.assign(SV, { OverviewView }); +})((window.SV = window.SV || {})); diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/PerformanceView.css b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/PerformanceView.css new file mode 100644 index 00000000..fbec8041 --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/PerformanceView.css @@ -0,0 +1,181 @@ +/* ============================================================================ + Performance tab styles — the monthly Sales-vs-period-average bar-in-bar + small-multiples, and the ranked Sub-Category Sales chart. Linked directly + from index.html (no build). All rules written against the shared .viz-root + role tokens declared in styles.css, so dark mode follows for free. + Namespaced `perf-` to avoid collisions with the other tab stylesheets. + ============================================================================ */ + +/* ---- Panel 1: Sales-performance-vs-average small multiples ---------------- */ +.perf-chart { + width: 100%; + overflow-x: auto; +} + +.perf-head { + display: flex; + align-items: flex-end; +} +.perf-head-spacer { + flex-shrink: 0; +} +.perf-col-head { + flex-shrink: 0; + text-align: center; + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + padding-bottom: 4px; +} + +.perf-group { + display: flex; + align-items: stretch; + border-top: 1px solid var(--gridline); +} +.perf-group-alt { + background: var(--surface-2); +} +.perf-month { + flex-shrink: 0; + font-size: 11px; + font-weight: 600; + color: var(--text-primary); + padding: 6px 6px 6px 2px; + white-space: nowrap; +} +.perf-seg-rows { + flex: 1 1 auto; + min-width: 0; +} +.perf-row { + display: flex; + align-items: center; + height: 20px; +} +.perf-seg-label { + flex-shrink: 0; + font-size: 10px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 4px; +} +.perf-cell { + flex-shrink: 0; + border-left: 1px solid var(--gridline); +} +.perf-bar-svg { + display: block; +} +.perf-rail { + fill: var(--surface-2); +} +.perf-tick { + stroke: var(--baseline); + stroke-width: 1.4; +} + +.perf-axis-row { + display: flex; + border-top: 1px solid var(--gridline); +} +.perf-axis-cell { + flex-shrink: 0; + border-left: 1px solid var(--gridline); +} + +/* ---- above/below-average swatch legend ------------------------------------ */ +.perf-legend-swatches { + display: inline-flex; + gap: 12px; + flex-shrink: 0; +} +.perf-swatch-item { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--text-secondary); +} +.perf-swatch { + width: 11px; + height: 11px; + border-radius: 3px; + display: inline-block; +} + +/* ---- Panel 2: ranked Sub-Category Sales ------------------------------------ */ +.perf-rank { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +} +.perf-rank-group + .perf-rank-group { + border-top: 1px solid var(--gridline); + padding-top: 8px; +} +.perf-rank-cat { + font-size: 11px; + font-weight: 600; + color: var(--text-primary); + text-transform: uppercase; + letter-spacing: 0.03em; + margin-bottom: 4px; +} +.perf-rank-row { + display: flex; + align-items: center; + gap: 8px; + height: 20px; +} +.perf-rank-label { + flex-shrink: 0; + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.perf-rank-svg { + flex: 1 1 auto; + min-width: 0; + display: block; +} +.perf-rank-val { + flex-shrink: 0; + font-size: 11px; + color: var(--text-primary); + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* ---- diverging profit-ratio legend (shared shape with the map legend) ----- */ +.perf-ratio-legend { + display: inline-flex; + align-items: center; + gap: 8px; +} +.perf-ratio-legend-lbl { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; +} +.perf-ratio-legend-bar { + width: 96px; + height: 10px; + border-radius: 3px; + border: 1px solid var(--border); + background: linear-gradient(to right, var(--div-neg), var(--div-mid), var(--div-pos)); +} +.perf-ratio-legend-ticks { + display: inline-flex; + gap: 6px; + font-size: 10px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/PerformanceView.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/PerformanceView.js new file mode 100644 index 00000000..425784fe --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/PerformanceView.js @@ -0,0 +1,512 @@ +(function (SV) { + 'use strict'; + var { + CATEGORIES, + MONTH_NAMES, + SEGMENTS, + TipRow, + clear, + dateFilters, + fmtPct, + fmtUSD0, + fmtUSDk, + frag, + getVal, + h, + makeRatioColor, + niceStep, + num, + observeWidth, + query, + regionFilters, + svg, + } = SV; + // ============================================================================ + // Performance tab — a reproduction of the Superstore "Sales Performance vs + // Target" sheet. The real workbook joins Orders against a separate "Sales + // Target" datasource (Category x Month x Segment x Year budget numbers) that + // is NOT part of this app's wired datasource (it has no Sales Target field). + // Reproducing the exact bullet-vs-target encoding would require inventing + // data, so this view substitutes an honestly-computed reference line: each + // Category x Segment's own mean monthly Sales across the current scope. The visual + // language (rail = reference, bar = actual, tick = reference position, color + // = above/below) is preserved from the source view; the reference itself is + // computed live like everything else, never embedded. + // 1. Sales Performance vs Period Average — small-multiples bar-in-bar, + // rows = Month x Segment (chronological), columns = Category. + // 2. Sub-Category Performance — ranked Sales bars grouped by Category, + // colored by the shared diverging profit-ratio ramp. + // ============================================================================ + + const errMsg = (err) => (err && (err.message || err.errorCode)) || String(err); + + // ---- layout constants (px) -------------------------------------------------- + const LABEL_W = 92; // month+year label column + const SEG_W = 78; // segment label column + const ROW_H = 20; // bar-in-bar row height + const AXIS_H = 26; + const PAD_R = 6; + + const RANK_LABEL_W = 92; + const RANK_VAL_W = 62; + const RANK_BAR_H = 16; + + // ---- fetch: Sales + Profit by Month x Category x Segment ------------------- + function fetchMonthlyPerf(ds, { region, dateSel } = {}) { + return query( + ds, + [ + { fieldCaption: 'Order Date', function: 'TRUNC_MONTH', fieldAlias: 'Month' }, + { fieldCaption: 'Category' }, + { fieldCaption: 'Segment' }, + { fieldCaption: 'Sales', function: 'SUM', fieldAlias: 'Sales' }, + { fieldCaption: 'Profit', function: 'SUM', fieldAlias: 'Profit' }, + ], + regionFilters(region).concat(dateFilters(dateSel)), + ).then((rows) => { + const periodsMap = new Map(); // t -> { t, year, month } + const cells = new Map(); // "t|cat|seg" -> { sales, profit } + const sums = new Map(); // "cat|seg" -> { sum, count } + let maxSales = 0; + rows.forEach((r) => { + const cat = r.Category; + const seg = r.Segment; + const mStr = getVal(r, 'Month', 'TRUNC_MONTH(Order Date)'); + if (cat == null || seg == null || mStr == null) return; + const d = new Date(mStr); + if (isNaN(d)) return; + const t = d.getTime(); + const sales = num(getVal(r, 'Sales', 'SUM(Sales)')); + const profit = num(getVal(r, 'Profit', 'SUM(Profit)')); + if (!periodsMap.has(t)) + periodsMap.set(t, { t, year: d.getFullYear(), month: d.getMonth() }); + cells.set(t + '|' + cat + '|' + seg, { sales, profit }); + const key = cat + '|' + seg; + const s = sums.get(key) || { sum: 0, count: 0 }; + s.sum += sales; + s.count += 1; + sums.set(key, s); + if (sales > maxSales) maxSales = sales; + }); + const periods = Array.from(periodsMap.values()).sort((a, b) => a.t - b.t); + const avg = new Map(); + sums.forEach((v, k) => avg.set(k, v.count ? v.sum / v.count : 0)); + return { periods, cells, avg, maxSales }; + }); + } + + // ---- fetch: Sales + Profit by Category x Sub-Category ---------------------- + function fetchSubcatPerf(ds, { region, dateSel } = {}) { + return query( + ds, + [ + { fieldCaption: 'Category' }, + { fieldCaption: 'Sub-Category' }, + { fieldCaption: 'Sales', function: 'SUM', fieldAlias: 'Sales' }, + { fieldCaption: 'Profit', function: 'SUM', fieldAlias: 'Profit' }, + ], + regionFilters(region).concat(dateFilters(dateSel)), + ).then((rows) => { + const items = []; + let maxSales = 0; + rows.forEach((r) => { + const category = r.Category; + const subCategory = r['Sub-Category']; + if (category == null || subCategory == null) return; + const sales = num(getVal(r, 'Sales', 'SUM(Sales)')); + const profit = num(getVal(r, 'Profit', 'SUM(Profit)')); + if (sales > maxSales) maxSales = sales; + items.push({ category, subCategory, sales, profit, ratio: sales ? profit / sales : 0 }); + }); + return { items, maxSales }; + }); + } + + // ---- Panel 1: one bar-in-bar cell (actual Sales vs the period-average rail) - + function PerfCell(cellW, xOf, sales, avgSales, onOver, onOut) { + const above = sales >= avgSales; + const barColor = above ? 'var(--div-pos)' : 'var(--div-neg)'; + const barW = Math.max(0, xOf(sales)); + const tickX = Math.max(0, xOf(avgSales)); + return svg( + 'svg', + { + class: 'perf-bar-svg', + width: cellW, + height: ROW_H, + viewBox: `0 0 ${cellW} ${ROW_H}`, + onPointerEnter: onOver, + onPointerMove: onOver, + onPointerLeave: onOut, + }, + svg('rect', { class: 'perf-rail', x: 0, y: 3, width: cellW - PAD_R, height: ROW_H - 6 }), + svg('rect', { + class: 'perf-bar', + x: 0, + y: 5, + width: barW, + height: ROW_H - 10, + fill: barColor, + }), + svg('line', { class: 'perf-tick', x1: tickX, x2: tickX, y1: 1, y2: ROW_H - 1 }), + ); + } + + function PerformanceBarChart(data, viz) { + const wrap = h('div', { class: 'perf-chart' }); + + function over(e, period, seg, cat, sales, profit, avgSales) { + const above = sales >= avgSales; + viz.showTip( + frag( + h( + 'div', + { class: 'tip-head' }, + `${MONTH_NAMES[period.month]} ${period.year} · ${cat} · ${seg}`, + ), + TipRow({ colorVar: '--series-sales', label: 'Sales', value: fmtUSD0.format(sales) }), + TipRow({ colorVar: '--series-profit', label: 'Profit', value: fmtUSD0.format(profit) }), + TipRow({ label: 'Profit ratio', value: fmtPct(sales ? profit / sales : 0, 1) }), + TipRow({ label: 'Period avg', value: fmtUSD0.format(avgSales) }), + TipRow({ label: above ? 'Above average' : 'Below average', value: '' }), + ), + e.clientX, + e.clientY, + ); + } + + function render(width) { + clear(wrap); + const cellW = Math.max( + 120, + Math.floor(((width || 720) - LABEL_W - SEG_W) / CATEGORIES.length), + ); + const step = niceStep(data.maxSales, 5) || 1000; + const domainMax = Math.max(step, Math.ceil(data.maxSales / step) * step); + const xOf = (v) => (Math.min(v, domainMax) / domainMax) * (cellW - PAD_R); + + const ticks = []; + for (let v = 0; v <= domainMax + 0.5; v += step) ticks.push(v); + + wrap.appendChild( + h( + 'div', + { class: 'perf-head' }, + h('div', { class: 'perf-head-spacer', style: { width: LABEL_W + SEG_W + 'px' } }), + CATEGORIES.map((cat) => + h('div', { class: 'perf-col-head', style: { width: cellW + 'px' } }, cat), + ), + ), + ); + + data.periods.forEach((period, pi) => { + wrap.appendChild( + h( + 'div', + { class: 'perf-group' + (pi % 2 ? ' perf-group-alt' : '') }, + h( + 'div', + { class: 'perf-month', style: { width: LABEL_W + 'px' } }, + `${MONTH_NAMES[period.month]} ${period.year}`, + ), + h( + 'div', + { class: 'perf-seg-rows' }, + SEGMENTS.map((seg) => + h( + 'div', + { class: 'perf-row' }, + h('div', { class: 'perf-seg-label', style: { width: SEG_W + 'px' } }, seg), + CATEGORIES.map((cat) => { + const cell = data.cells.get(period.t + '|' + cat + '|' + seg); + const sales = cell ? cell.sales : 0; + const profit = cell ? cell.profit : 0; + const avgSales = data.avg.get(cat + '|' + seg) || 0; + return h( + 'div', + { class: 'perf-cell', style: { width: cellW + 'px' } }, + PerfCell( + cellW, + xOf, + sales, + avgSales, + (e) => over(e, period, seg, cat, sales, profit, avgSales), + () => viz.hideTip(), + ), + ); + }), + ), + ), + ), + ), + ); + }); + + wrap.appendChild( + h( + 'div', + { class: 'perf-axis-row' }, + h('div', { class: 'perf-head-spacer', style: { width: LABEL_W + SEG_W + 'px' } }), + CATEGORIES.map(() => + h( + 'div', + { class: 'perf-axis-cell', style: { width: cellW + 'px' } }, + svg( + 'svg', + { width: cellW, height: AXIS_H, viewBox: `0 0 ${cellW} ${AXIS_H}` }, + ticks.map((v) => { + const tx = xOf(v); + return svg( + 'g', + null, + svg('line', { class: 'grid', x1: tx, y1: 0, x2: tx, y2: 4 }), + svg( + 'text', + { class: 'x-lbl', x: tx, y: 14, 'text-anchor': 'middle' }, + v === 0 ? '$0' : fmtUSDk(v), + ), + ); + }), + ), + ), + ), + ), + ); + } + + const disconnect = observeWidth(wrap, render); + return { el: wrap, disconnect }; + } + + function AvgLegend() { + return h( + 'div', + { class: 'perf-legend-swatches' }, + h( + 'span', + { class: 'perf-swatch-item' }, + h('span', { class: 'perf-swatch', style: { background: 'var(--div-pos)' } }), + 'Above period avg.', + ), + h( + 'span', + { class: 'perf-swatch-item' }, + h('span', { class: 'perf-swatch', style: { background: 'var(--div-neg)' } }), + 'Below period avg.', + ), + ); + } + + // ---- Panel 2: ranked Sub-Category Sales, colored by profit ratio ----------- + function SubCategoryRanked(data, vizRootEl, viz) { + const color = makeRatioColor(vizRootEl); + const wrap = h('div', { class: 'perf-rank' }); + + function over(e, item) { + viz.showTip( + frag( + h('div', { class: 'tip-head' }, `${item.category} · ${item.subCategory}`), + TipRow({ colorVar: '--series-sales', label: 'Sales', value: fmtUSD0.format(item.sales) }), + TipRow({ + colorVar: '--series-profit', + label: 'Profit', + value: fmtUSD0.format(item.profit), + }), + TipRow({ label: 'Profit ratio', value: fmtPct(item.ratio, 1) }), + ), + e.clientX, + e.clientY, + ); + } + + function render(width) { + clear(wrap); + const step = niceStep(data.maxSales, 5) || 1000; + const domainMax = Math.max(step, Math.ceil(data.maxSales / step) * step); + const plotW = Math.max(160, (width || 640) - RANK_LABEL_W - RANK_VAL_W - 16); + const xOf = (v) => (Math.min(v, domainMax) / domainMax) * plotW; + + const groups = CATEGORIES.map((cat) => ({ + cat, + items: data.items.filter((it) => it.category === cat).sort((a, b) => b.sales - a.sales), + })).filter((g) => g.items.length); + + groups.forEach((g) => { + wrap.appendChild( + h( + 'div', + { class: 'perf-rank-group' }, + h('div', { class: 'perf-rank-cat' }, g.cat), + g.items.map((it) => + h( + 'div', + { class: 'perf-rank-row' }, + h( + 'div', + { class: 'perf-rank-label', style: { width: RANK_LABEL_W + 'px' } }, + it.subCategory, + ), + svg( + 'svg', + { + class: 'perf-rank-svg', + width: plotW, + height: RANK_BAR_H, + viewBox: `0 0 ${plotW} ${RANK_BAR_H}`, + onPointerEnter: (e) => over(e, it), + onPointerMove: (e) => over(e, it), + onPointerLeave: () => viz.hideTip(), + }, + svg('rect', { + x: 0, + y: 2, + width: xOf(it.sales), + height: RANK_BAR_H - 4, + fill: color(it.ratio), + rx: 2, + }), + ), + h( + 'div', + { class: 'perf-rank-val', style: { width: RANK_VAL_W + 'px' } }, + fmtUSD0.format(it.sales), + ), + ), + ), + ), + ); + }); + } + + const disconnect = observeWidth(wrap, render); + return { el: wrap, disconnect }; + } + + function RatioLegend() { + return h( + 'div', + { class: 'perf-ratio-legend' }, + h('span', { class: 'perf-ratio-legend-lbl' }, 'Profit Ratio'), + h('span', { class: 'perf-ratio-legend-bar' }), + h( + 'span', + { class: 'perf-ratio-legend-ticks' }, + h('span', null, '−50%'), + h('span', null, '+50%'), + ), + ); + } + + const muted = (t) => h('p', { class: 'muted' }, t); + const errP = (t) => h('p', { class: 'error' }, t); + function setBody(slot, node) { + clear(slot); + slot.appendChild(node); + } + + function PerformanceView({ ds, viz }) { + const vizRootEl = viz.vizRootEl; + let region = null, + dateSel = null; + let token = 0; + let perfDc = null, + rankDc = null; + + const perfSlot = h('div', null, muted('Loading…')); + const rankSlot = h('div', null, muted('Loading…')); + + const el = h( + 'div', + { style: { display: 'contents' } }, + h( + 'section', + { class: 'card' }, + h( + 'div', + { class: 'panel-head' }, + h('h2', { class: 'card-title' }, 'Sales Performance vs Period Average'), + AvgLegend(), + ), + perfSlot, + ), + h( + 'section', + { class: 'card' }, + h( + 'div', + { class: 'panel-head' }, + h('h2', { class: 'card-title' }, 'Sub-Category Performance'), + RatioLegend(), + ), + rankSlot, + ), + ); + + function clearPerf() { + if (perfDc) { + perfDc(); + perfDc = null; + } + } + function clearRank() { + if (rankDc) { + rankDc(); + rankDc = null; + } + } + + function load() { + const my = ++token; + viz.beginLoad(); + const p1p = fetchMonthlyPerf(ds, { region, dateSel }) + .then((d) => { + if (my !== token) return; + clearPerf(); + if (d.periods.length) { + const c = PerformanceBarChart(d, viz); + perfDc = c.disconnect; + setBody(perfSlot, c.el); + } else setBody(perfSlot, muted('No data in current scope.')); + }) + .catch((e) => { + if (my === token) { + clearPerf(); + setBody(perfSlot, errP('Could not load performance: ' + errMsg(e))); + } + }); + const p2p = fetchSubcatPerf(ds, { region, dateSel }) + .then((d) => { + if (my !== token) return; + clearRank(); + if (d.items.length) { + const c = SubCategoryRanked(d, vizRootEl, viz); + rankDc = c.disconnect; + setBody(rankSlot, c.el); + } else setBody(rankSlot, muted('No data in current scope.')); + }) + .catch((e) => { + if (my === token) { + clearRank(); + setBody(rankSlot, errP('Could not load sub-category performance: ' + errMsg(e))); + } + }); + Promise.all([p1p, p2p]).then(() => viz.endLoad()); + } + + return { + el, + update({ region: r, dateSel: d }) { + region = r ?? null; + dateSel = d ?? null; + load(); + }, + destroy() { + token++; + clearPerf(); + clearRank(); + }, + }; + } + + Object.assign(SV, { PerformanceView }); +})((window.SV = window.SV || {})); diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/ProductView.css b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/ProductView.css new file mode 100644 index 00000000..c3940538 --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/ProductView.css @@ -0,0 +1,163 @@ +/* ============================================================================ + Product tab styles — the Sales-by-Category highlight table and the + per-product Sales/Profit strip plot. Linked directly from index.html (no + build). All rules written against the shared .viz-root role tokens declared + in styles.css, so dark mode follows for free. + ============================================================================ */ + +/* ---- Panel 1: Sales-by-Category highlight table -------------------------- */ +.heat-scroll { + overflow-x: auto; +} +.heat-table { + border-collapse: collapse; + width: 100%; + font-variant-numeric: tabular-nums; +} +.heat-table th, +.heat-table td { + border: 1px solid var(--surface-1); +} +.heat-corner { + background: transparent; + border-color: transparent; +} +.heat-col { + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + text-align: center; + padding: 2px 5px; +} +.heat-cat { + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + text-align: left; + vertical-align: top; + padding: 4px 8px 4px 2px; + white-space: nowrap; +} +.heat-year { + font-size: 11px; + color: var(--text-secondary); + text-align: right; + padding: 2px 8px 2px 2px; +} +.heat-cell { + font-size: 10px; + text-align: right; + padding: 3px 5px; + white-space: nowrap; + min-width: 46px; +} +.heat-empty { + background: var(--surface-2); +} + +/* ---- Panel 2: per-product strip plot ------------------------------------- */ +.scatter { + width: 100%; + overflow-x: auto; +} +.scatter-head { + display: flex; + align-items: flex-end; +} +.scatter-head-spacer { + flex-shrink: 0; +} +.scatter-seg { + flex-shrink: 0; + text-align: center; + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + padding-bottom: 4px; +} +.scatter-group { + display: flex; + align-items: stretch; + border-top: 1px solid var(--gridline); +} +.scatter-cat { + flex-shrink: 0; + font-size: 11px; + font-weight: 600; + color: var(--text-primary); + padding: 6px 6px 0 0; +} +.scatter-rows { + flex: 1 1 auto; + min-width: 0; +} +.scatter-row { + display: flex; + align-items: center; + height: 22px; +} +.scatter-sub { + flex-shrink: 0; + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 6px; +} +.scatter-cell { + flex-shrink: 0; + border-left: 1px solid var(--gridline); +} +.scatter-cell svg { + display: block; +} +.scatter-med { + stroke: var(--baseline); + stroke-width: 1; +} +.scatter-dot { + opacity: 0.85; +} + +.scatter-axis-row { + display: flex; + border-top: 1px solid var(--gridline); +} +.scatter-axis-cell { + flex-shrink: 0; + border-left: 1px solid var(--gridline); +} +.scatter-axis-title { + fill: var(--text-muted); + font-size: 10px; + font-family: inherit; +} + +/* ---- diverging profit-ratio legend (shared shape with the map legend) ---- */ +.scatter-legend { + display: inline-flex; + align-items: center; + gap: 8px; +} +.scatter-legend-lbl { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; +} +.scatter-legend-bar { + width: 96px; + height: 10px; + border-radius: 3px; + border: 1px solid var(--border); + background: linear-gradient(to right, var(--div-neg), var(--div-mid), var(--div-pos)); +} +.scatter-legend-ticks { + display: inline-flex; + gap: 6px; + font-size: 10px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/ProductView.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/ProductView.js new file mode 100644 index 00000000..5a9a84b3 --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/ProductView.js @@ -0,0 +1,418 @@ +(function (SV) { + 'use strict'; + var { + CATEGORIES, + MONTH_NAMES, + SEGMENTS, + SUB_CATEGORIES, + TipRow, + clear, + fetchCategorySales, + fetchProductScatter, + fmtPct, + fmtUSD0, + fmtUSDk, + frag, + h, + makeRatioColor, + makeSalesColor, + niceStep, + observeWidth, + svg, + } = SV; + // ============================================================================ + // Product tab — a reproduction of the Superstore "Product Drilldown" sheet, as + // a second in-app view. Two live-queried panels, sharing the Overview's Region + + // Order Date scope: + // 1. Sales by Product Category — a highlight table (Category × Year rows, month + // columns) whose cells are shaded by a sequential Sales ramp. + // 2. Sales and Profit by Product Names — a strip plot: one dot per product, + // laid out by Sub-Category (rows) × Segment (columns), positioned by Sales + // and colored by the diverging profit-ratio ramp (same one the map uses). + // Data carries no embedded values; both panels query the wired datasource live. + // ============================================================================ + + const errMsg = (err) => (err && (err.message || err.errorCode)) || String(err); + + // ---- strip-plot layout (px; the SVGs render 1:1 so dots stay circular) ------ + const BAND_H = 22; // height of one Sub-Category row + const AXIS_H = 26; + const CAT_W = 64; // left Category rail + const SUB_W = 88; // Sub-Category label column + const PAD_L = 2; + const PAD_R = 8; + + // Stable pseudo-vertical-jitter per product name so dots spread within a band + // without overplotting — deterministic (no Math.random) so renders don't jump. + function jitterY(name) { + let hh = 0; + const s = name || ''; + for (let i = 0; i < s.length; i++) hh = (hh * 31 + s.charCodeAt(i)) | 0; + const f = (((hh % 1000) + 1000) % 1000) / 1000 - 0.5; // -0.5..0.5 + return BAND_H / 2 + f * BAND_H * 0.62; + } + function median(pts) { + if (!pts.length) return null; + const s = pts.map((p) => p.sales).sort((a, b) => a - b); + const m = Math.floor(s.length / 2); + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; + } + + // ---- Panel 1: Sales-by-Category highlight table ----------------------------- + function CategoryHeatmap(data, vizRootEl, viz) { + const scale = makeSalesColor(vizRootEl, data.minSales, data.maxSales); + + function over(e, cat, mo, yr, cell) { + if (!cell) { + viz.hideTip(); + return; + } + viz.showTip( + frag( + h('div', { class: 'tip-head' }, `${cat} · ${MONTH_NAMES[mo]} ${yr}`), + TipRow({ colorVar: '--series-sales', label: 'Sales', value: fmtUSD0.format(cell.sales) }), + TipRow({ + colorVar: '--series-profit', + label: 'Profit', + value: fmtUSD0.format(cell.profit), + }), + TipRow({ + label: 'Profit ratio', + value: fmtPct(cell.sales ? cell.profit / cell.sales : 0, 1), + }), + ), + e.clientX, + e.clientY, + ); + } + + const body = data.rows.map((row) => + h( + 'tr', + null, + row.firstOfCat ? h('th', { class: 'heat-cat', rowspan: row.catSpan }, row.category) : null, + h('th', { class: 'heat-year' }, row.year), + row.cells.map((cell, mo) => { + if (!cell) return h('td', { class: 'heat-cell heat-empty' }); + const dark = scale.t(cell.sales) > 0.55; + return h( + 'td', + { + class: 'heat-cell', + style: { + background: scale.color(cell.sales), + color: dark ? '#fff' : 'var(--text-primary)', + }, + onPointerEnter: (e) => over(e, row.category, mo, row.year, cell), + onPointerMove: (e) => over(e, row.category, mo, row.year, cell), + onPointerLeave: () => viz.hideTip(), + }, + fmtUSD0.format(cell.sales), + ); + }), + ), + ); + + return h( + 'div', + { class: 'heat-scroll' }, + h( + 'table', + { class: 'heat-table' }, + h( + 'thead', + null, + h( + 'tr', + null, + h('th', { class: 'heat-corner', colspan: 2 }), + MONTH_NAMES.map((m) => h('th', { class: 'heat-col' }, m)), + ), + ), + h('tbody', null, body), + ), + ); + } + + // One (Sub-Category × Segment) cell: dots by Sales, a median reference line, and + // a shared pointer handler that tooltips the nearest product (no per-dot events). + function ScatterCell(pts, cellW, xOf, color, viz) { + const dots = pts.map((p) => ({ p, cx: xOf(p.sales), cy: jitterY(p.name) })); + const med = median(pts); + let svgEl; + + function move(e) { + if (!dots.length) return; + const box = svgEl.getBoundingClientRect(); + const sx = ((e.clientX - box.left) / box.width) * cellW; + const sy = ((e.clientY - box.top) / box.height) * BAND_H; + let best = null, + bd = Infinity; + dots.forEach((d) => { + const dx = d.cx - sx, + dy = d.cy - sy, + q = dx * dx + dy * dy; + if (q < bd) { + bd = q; + best = d.p; + } + }); + if (!best) return; + viz.showTip( + frag( + h('div', { class: 'tip-head' }, best.name), + TipRow({ colorVar: '--series-sales', label: 'Sales', value: fmtUSD0.format(best.sales) }), + TipRow({ + colorVar: '--series-profit', + label: 'Profit', + value: fmtUSD0.format(best.profit), + }), + TipRow({ label: 'Profit ratio', value: fmtPct(best.ratio, 1) }), + ), + e.clientX, + e.clientY, + ); + } + + svgEl = svg( + 'svg', + { + width: cellW, + height: BAND_H, + viewBox: `0 0 ${cellW} ${BAND_H}`, + onPointerMove: move, + onPointerLeave: () => viz.hideTip(), + }, + med != null + ? svg('line', { + class: 'scatter-med', + x1: xOf(med), + x2: xOf(med), + y1: 2.5, + y2: BAND_H - 2.5, + }) + : null, + dots.map((d) => + svg('circle', { class: 'scatter-dot', cx: d.cx, cy: d.cy, r: 3, fill: color(d.p.ratio) }), + ), + ); + + return h('div', { class: 'scatter-cell' }, svgEl); + } + + // ---- Panel 2: per-product strip plot ---------------------------------------- + function ProductScatter(data, vizRootEl, viz) { + const color = makeRatioColor(vizRootEl); + const wrap = h('div', { class: 'scatter' }); + + // subCategory|segment -> points + const byKey = new Map(); + data.pts.forEach((p) => { + const k = p.subCategory + '|' + p.segment; + const arr = byKey.get(k); + if (arr) arr.push(p); + else byKey.set(k, [p]); + }); + + function render(width) { + clear(wrap); + const cellW = Math.max(120, Math.floor(((width || 720) - CAT_W - SUB_W) / SEGMENTS.length)); + const step = niceStep(data.maxSales, 6) || 5000; + const domainMax = Math.max(step, Math.ceil(data.maxSales / step) * step); + const xOf = (sales) => + PAD_L + (Math.min(sales, domainMax) / domainMax) * (cellW - PAD_L - PAD_R); + + const ticks = []; + for (let v = 0; v <= domainMax + 0.5; v += step) ticks.push(v); + + wrap.appendChild( + h( + 'div', + { class: 'scatter-head' }, + h('div', { class: 'scatter-head-spacer', style: { width: CAT_W + SUB_W + 'px' } }), + SEGMENTS.map((seg) => + h('div', { class: 'scatter-seg', style: { width: cellW + 'px' } }, seg), + ), + ), + ); + + CATEGORIES.forEach((cat) => { + wrap.appendChild( + h( + 'div', + { class: 'scatter-group' }, + h('div', { class: 'scatter-cat', style: { width: CAT_W + 'px' } }, cat), + h( + 'div', + { class: 'scatter-rows' }, + (SUB_CATEGORIES[cat] || []).map((sub) => + h( + 'div', + { class: 'scatter-row' }, + h('div', { class: 'scatter-sub', style: { width: SUB_W + 'px' } }, sub), + SEGMENTS.map((seg) => + ScatterCell(byKey.get(sub + '|' + seg) || [], cellW, xOf, color, viz), + ), + ), + ), + ), + ), + ); + }); + + wrap.appendChild( + h( + 'div', + { class: 'scatter-axis-row' }, + h('div', { class: 'scatter-head-spacer', style: { width: CAT_W + SUB_W + 'px' } }), + SEGMENTS.map(() => + h( + 'div', + { class: 'scatter-axis-cell', style: { width: cellW + 'px' } }, + svg( + 'svg', + { width: cellW, height: AXIS_H, viewBox: `0 0 ${cellW} ${AXIS_H}` }, + ticks.map((v) => { + const tx = xOf(v); + return svg( + 'g', + null, + svg('line', { class: 'grid', x1: tx, y1: 0, x2: tx, y2: 4 }), + svg( + 'text', + { class: 'x-lbl', x: tx, y: 14, 'text-anchor': 'middle' }, + v === 0 ? '$0' : fmtUSDk(v), + ), + ); + }), + svg( + 'text', + { + class: 'scatter-axis-title', + x: PAD_L + (cellW - PAD_L - PAD_R) / 2, + y: 24, + 'text-anchor': 'middle', + }, + 'Sales', + ), + ), + ), + ), + ), + ); + } + + const disconnect = observeWidth(wrap, render); + return { el: wrap, disconnect }; + } + + function RatioLegend() { + return h( + 'div', + { class: 'scatter-legend' }, + h('span', { class: 'scatter-legend-lbl' }, 'Profit Ratio'), + h('span', { class: 'scatter-legend-bar' }), + h( + 'span', + { class: 'scatter-legend-ticks' }, + h('span', null, '−50%'), + h('span', null, '+50%'), + ), + ); + } + + const muted = (t) => h('p', { class: 'muted' }, t); + const errP = (t) => h('p', { class: 'error' }, t); + function setBody(slot, node) { + clear(slot); + slot.appendChild(node); + } + + function ProductView({ ds, viz }) { + const vizRootEl = viz.vizRootEl; + let region = null, + dateSel = null; + let token = 0; + let scatterDisconnect = null; + + const p1 = h('div', null, muted('Loading…')); + const p2 = h('div', null, muted('Loading…')); + + const el = h( + 'div', + { style: { display: 'contents' } }, + h( + 'section', + { class: 'card' }, + h('h2', { class: 'card-title' }, 'Sales by Product Category'), + p1, + ), + h( + 'section', + { class: 'card' }, + h( + 'div', + { class: 'panel-head' }, + h('h2', { class: 'card-title' }, 'Sales and Profit by Product Names'), + RatioLegend(), + ), + p2, + ), + ); + + function clearScatter() { + if (scatterDisconnect) { + scatterDisconnect(); + scatterDisconnect = null; + } + } + + function load() { + const my = ++token; + viz.beginLoad(); + const p1p = fetchCategorySales(ds, { region, dateSel }) + .then((d) => { + if (my !== token) return; + setBody( + p1, + d.rows.length ? CategoryHeatmap(d, vizRootEl, viz) : muted('No data in current scope.'), + ); + }) + .catch((e) => { + if (my === token) setBody(p1, errP('Could not load category sales: ' + errMsg(e))); + }); + const p2p = fetchProductScatter(ds, { region, dateSel }) + .then((d) => { + if (my !== token) return; + clearScatter(); + if (d.pts.length) { + const sc = ProductScatter(d, vizRootEl, viz); + scatterDisconnect = sc.disconnect; + setBody(p2, sc.el); + } else setBody(p2, muted('No products in current scope.')); + }) + .catch((e) => { + if (my === token) { + clearScatter(); + setBody(p2, errP('Could not load products: ' + errMsg(e))); + } + }); + Promise.all([p1p, p2p]).then(() => viz.endLoad()); + } + + return { + el, + update({ region: r, dateSel: d }) { + region = r ?? null; + dateSel = d ?? null; + load(); + }, + destroy() { + token++; + clearScatter(); + }, + }; + } + + Object.assign(SV, { ProductView }); +})((window.SV = window.SV || {})); diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/RangeSlider.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/RangeSlider.js new file mode 100644 index 00000000..582448e8 --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/RangeSlider.js @@ -0,0 +1,191 @@ +(function (SV) { + 'use strict'; + var { h } = SV; + // ============================================================================ + // Dual-thumb range slider — dependency-free, pointer + keyboard accessible. + // + // Controlled by an initial `value` [lo, hi] in domain units (min..max). Fires + // two callbacks so the parent can split cheap visual updates from expensive work: + // onInput(next) — continuous, on every drag move / key repeat (move a label) + // onCommit(next) — once, on pointer release / key-up (trigger a live refetch) + // For a purely client-side filter, wire both to the same handler. + // + // The slider owns its live position and repaints the thumbs/fill/labels + // imperatively — so a drag + // never re-renders the map or panels. The lo thumb can't cross above hi and + // vice-versa (clamped, never swapped). Drag is tracked at the window level so the + // thumb keeps following the pointer even when it leaves the track. All values + // snap to `step`. Returns { el, setValue } — setValue repaints without callbacks. + // ============================================================================ + function RangeSlider({ + min, + max, + step = 1, + value, + onInput, + onCommit, + format = (v) => String(v), + label, + }) { + let cur = value.slice(); + let dragging = null; // 'lo' | 'hi' | null + let commitPending = false; // a key changed the value → commit on key-up + + const span = max - min || 1; + const pct = (v) => Math.max(0, Math.min(100, ((v - min) / span) * 100)); + const snap = (v) => { + const s = Math.round((v - min) / step) * step + min; + return Math.max(min, Math.min(max, s)); + }; + const rawFromX = (clientX) => { + const rect = track.getBoundingClientRect(); + const t = rect.width ? (clientX - rect.left) / rect.width : 0; + return snap(min + t * span); + }; + // clamp so a thumb never crosses its sibling + const nextFor = (which, raw, c) => + which === 'lo' ? [Math.min(raw, c[1]), c[1]] : [c[0], Math.max(raw, c[0])]; + + const loVal = h('span', { class: 'rs-val' }, format(cur[0])); + const hiVal = h('span', { class: 'rs-val' }, format(cur[1])); + const fill = h('span', { class: 'rs-fill' }); + + const makeThumb = (which) => + h('button', { + type: 'button', + class: 'rs-thumb', + role: 'slider', + 'aria-label': (label ? label + ' ' : '') + (which === 'lo' ? 'minimum' : 'maximum'), + onPointerDown: (e) => { + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.focus(); + startDrag(which); + }, + onKeyDown: (e) => onThumbKey(which, e), + onKeyUp: onThumbKeyUp, + }); + const loThumb = makeThumb('lo'); + const hiThumb = makeThumb('hi'); + + const track = h( + 'span', + { class: 'rs-track', onPointerDown: onTrackDown }, + h('span', { class: 'rs-rail' }), + fill, + loThumb, + hiThumb, + ); + const root = h('span', { class: 'rangeslider' }, loVal, track, hiVal); + + function setAria(thumb, which) { + const v = which === 'lo' ? cur[0] : cur[1]; + thumb.setAttribute('aria-valuemin', which === 'lo' ? min : cur[0]); + thumb.setAttribute('aria-valuemax', which === 'lo' ? cur[1] : max); + thumb.setAttribute('aria-valuenow', v); + thumb.setAttribute('aria-valuetext', format(v)); + } + function reflect() { + loVal.textContent = format(cur[0]); + hiVal.textContent = format(cur[1]); + fill.style.left = pct(cur[0]) + '%'; + fill.style.right = 100 - pct(cur[1]) + '%'; + loThumb.style.left = pct(cur[0]) + '%'; + hiThumb.style.left = pct(cur[1]) + '%'; + setAria(loThumb, 'lo'); + setAria(hiThumb, 'hi'); + } + // Set the live value, repaint, and notify onInput (the continuous channel). + function apply(next) { + cur = next; + reflect(); + if (onInput) onInput(cur.slice()); + } + + function onTrackDown(e) { + const raw = rawFromX(e.clientX); + if (raw == null) return; + const c = cur; + // A press outside the span grabs the thumb on that side (so coincident + // thumbs, lo===hi, can still be pulled apart); between them, nearest wins. + const which = + raw > c[1] + ? 'hi' + : raw < c[0] + ? 'lo' + : Math.abs(raw - c[0]) <= Math.abs(raw - c[1]) + ? 'lo' + : 'hi'; + apply(nextFor(which, raw, c)); + startDrag(which); + } + + function onMove(e) { + const raw = rawFromX(e.clientX); + if (raw != null) apply(nextFor(dragging, raw, cur)); + } + function onUp() { + (dragging === 'lo' ? loThumb : hiThumb).classList.remove('is-drag'); + dragging = null; + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + if (onCommit) onCommit(cur.slice()); + } + function startDrag(which) { + dragging = which; + (which === 'lo' ? loThumb : hiThumb).classList.add('is-drag'); + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + } + + function onThumbKey(which, e) { + const big = step * 10; + let d = 0; + switch (e.key) { + case 'ArrowLeft': + case 'ArrowDown': + d = -step; + break; + case 'ArrowRight': + case 'ArrowUp': + d = step; + break; + case 'PageDown': + d = -big; + break; + case 'PageUp': + d = big; + break; + case 'Home': + d = -Infinity; + break; + case 'End': + d = Infinity; + break; + default: + return; + } + e.preventDefault(); + const base = which === 'lo' ? cur[0] : cur[1]; + const nv = snap(d === Infinity ? max : d === -Infinity ? min : base + d); + apply(nextFor(which, nv, cur)); + commitPending = true; + } + function onThumbKeyUp() { + if (!commitPending) return; + commitPending = false; + if (onCommit) onCommit(cur.slice()); + } + + reflect(); + return { + el: root, + setValue(next) { + cur = next.slice(); + reflect(); + }, + }; + } + + Object.assign(SV, { RangeSlider }); +})((window.SV = window.SV || {})); diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/RegionFilter.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/RegionFilter.js new file mode 100644 index 00000000..84333c9e --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/RegionFilter.js @@ -0,0 +1,22 @@ +(function (SV) { + 'use strict'; + var { REGIONS, h } = SV; + // Region scope selector — "All" (null) plus the four regions. Scopes the whole + // view; sits in the filter row above the content it affects. onChange receives + // the region name or null for All. + function RegionFilter(region, onChange) { + const select = h( + 'select', + { + class: 'filter-select', + onChange: (e) => onChange(e.target.value || null), + }, + h('option', { value: '' }, 'All'), + REGIONS.map((r) => h('option', { value: r }, r)), + ); + select.value = region || ''; + return h('label', { class: 'filter' }, h('span', { class: 'filter-label' }, 'Region'), select); + } + + Object.assign(SV, { RegionFilter }); +})((window.SV = window.SV || {})); diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/SelectionChip.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/SelectionChip.js new file mode 100644 index 00000000..a83cd34b --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/SelectionChip.js @@ -0,0 +1,17 @@ +(function (SV) { + 'use strict'; + var { h, selectionLabel } = SV; + // Names the picked states and offers a Clear button. `selected` is an array of + // state names; returns null when empty (caller hides the slot). + function SelectionChip(selected, onClear) { + if (!selected.length) return null; + return h( + 'div', + { class: 'sel-chip' }, + h('span', { class: 'sel-chip-text' }, 'States: ' + selectionLabel(selected)), + h('button', { type: 'button', class: 'sel-chip-clear', onClick: onClear }, 'Clear'), + ); + } + + Object.assign(SV, { SelectionChip }); +})((window.SV = window.SV || {})); diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/ShippingView.css b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/ShippingView.css new file mode 100644 index 00000000..383d9d99 --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/ShippingView.css @@ -0,0 +1,123 @@ +/* ============================================================================ + Shipping tab styles — the On-Time Shipment Trends header bar + monthly area + chart, and the per-Ship-Mode breakdown rows. Linked directly from + index.html (no build). All rules written against the shared .viz-root role + tokens declared in styles.css, so dark mode follows for free. Reuses (does + not redefine) the shared .series-legend/.grid/.y-lbl/.x-lbl/.crosshair/.hit + rules already declared in styles.css. + ============================================================================ */ + +/* ---- Early / On Time / Late percent bar ----------------------------------- */ +.ship-bar { + display: flex; + width: 100%; + height: 22px; + border-radius: 5px; + overflow: hidden; + border: 1px solid var(--border); +} +.ship-bar-big { + height: 40px; + border-radius: 6px; + margin-bottom: 10px; +} + +.ship-bar-seg { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + overflow: hidden; + white-space: nowrap; + cursor: default; + transition: opacity 0.1s ease; +} +.ship-bar-seg:hover { + opacity: 0.85; +} + +.ship-seg-early { + background: var(--div-pos); + color: #fff; +} +.ship-seg-ontime { + background: var(--div-mid); + color: var(--text-primary); +} +.ship-seg-late { + background: var(--div-neg); + color: #fff; +} + +.ship-bar-text { + font-size: 12px; + padding: 0 8px; + overflow: hidden; + text-overflow: ellipsis; +} +.ship-bar-text strong { + font-size: 14px; + margin-right: 4px; +} + +/* ---- monthly stacked-area trend -------------------------------------------- */ +.ship-chart { + width: 100%; +} +.ship-chart-svg { + display: block; + width: 100%; +} + +.ship-area-early { + fill: var(--div-pos); + opacity: 0.75; + stroke: none; +} +.ship-area-ontime { + fill: var(--div-mid); + opacity: 0.9; + stroke: none; +} +.ship-area-late { + fill: var(--div-neg); + opacity: 0.75; + stroke: none; +} + +/* ---- per-Ship-Mode breakdown ------------------------------------------------ */ +.ship-modes { + display: flex; + flex-direction: column; + gap: 10px; +} +.ship-mode-row { + display: grid; + grid-template-columns: 110px 1fr 190px; + align-items: center; + gap: 10px; +} +.ship-mode-name { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ship-mode-meta { + font-size: 11px; + color: var(--text-muted); + text-align: right; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +@media (max-width: 700px) { + .ship-mode-row { + grid-template-columns: 90px 1fr; + } + .ship-mode-meta { + grid-column: 1 / -1; + text-align: left; + } +} diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/ShippingView.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/ShippingView.js new file mode 100644 index 00000000..f6ac1ddc --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/ShippingView.js @@ -0,0 +1,510 @@ +(function (SV) { + 'use strict'; + var { + MONTH_NAMES, + TipRow, + clear, + dateFilters, + fmtInt, + fmtPct, + frag, + h, + niceStep, + observeWidth, + query, + regionFilters, + svg, + } = SV; + // ============================================================================ + // Shipping tab — a reproduction of the Superstore "On-Time Shipment Trends" + // sheet. The datasource carries no explicit on-time/SLA field, so "Early / + // On Time / Late" is derived client-side from two live-queried date columns: + // days-to-ship = Ship Date − Order Date (per order), classified against a + // fixed expected-transit-days table keyed by Ship Mode (the closest thing + // the classic Superstore Ship Mode semantics offer to an SLA — Same Day + // fastest, Standard Class slowest). This mapping is a display convention, + // not queried data; only Order ID / Order Date / Ship Date / Ship Mode are + // fetched live, scoped by the shared Region + Order Date filters. + // + // Panels: + // 1. On-Time Shipment Trends — overall Early/On Time/Late % bar, plus a + // monthly stacked-area trend of shipment counts by that classification. + // 2. On-Time Performance by Ship Mode — same classification broken out per + // Ship Mode, with order count and average days-to-ship. + // ============================================================================ + + const errMsg = (err) => (err && (err.message || err.errorCode)) || String(err); + + // Expected transit days (Ship Date − Order Date) per Ship Mode. daysToShip + // below this → Early, equal → On Time, above → Late. Unmapped modes are + // dropped rather than guessed at. + const EXPECTED_DAYS = { 'Same Day': 0, 'First Class': 2, 'Second Class': 4, 'Standard Class': 6 }; + const SHIP_MODE_ORDER = ['Same Day', 'First Class', 'Second Class', 'Standard Class']; + + const BUCKET = { Early: 'early', 'On Time': 'onTime', Late: 'late' }; + const COLOR_VAR = { early: '--div-pos', onTime: '--div-mid', late: '--div-neg' }; + + function classify(daysToShip, mode) { + const target = EXPECTED_DAYS[mode]; + if (target == null) return null; + if (daysToShip < target) return 'Early'; + if (daysToShip > target) return 'Late'; + return 'On Time'; + } + + // Parse a VDS date value at LOCAL noon. VDS returns Order/Ship Date as a bare + // 'YYYY-MM-DD' string; `new Date('YYYY-MM-DD')` parses as UTC midnight, so in any + // timezone west of UTC the local getFullYear()/getMonth() roll first-of-month + // dates back a month (Jan-1 → prior year), mis-bucketing the monthly trend. + // Anchoring at noon keeps both the day-diff and the month key timezone-stable. + function localDate(x) { + const m = String(x).match(/^(\d{4})-(\d{2})-(\d{2})/); + if (m) return new Date(+m[1], +m[2] - 1, +m[3], 12); + const d = new Date(x); + return isNaN(d) ? null : d; + } + + // ---- fetch + client-side classification ------------------------------------- + function fetchShipping(ds, { region, dateSel }) { + return query( + ds, + [ + { fieldCaption: 'Order ID' }, + { fieldCaption: 'Order Date' }, + { fieldCaption: 'Ship Date' }, + { fieldCaption: 'Ship Mode' }, + ], + regionFilters(region).concat(dateFilters(dateSel)), + ).then((rows) => { + const seen = new Set(); + const overall = { early: 0, onTime: 0, late: 0, total: 0 }; + const byMode = Object.create(null); + const monthly = Object.create(null); + let minT = Infinity, + maxT = -Infinity; + + rows.forEach((r) => { + const id = r['Order ID']; + if (id == null || seen.has(id)) return; + seen.add(id); + + const od = r['Order Date'], + sd = r['Ship Date'], + mode = r['Ship Mode']; + if (od == null || sd == null || mode == null) return; + const orderDate = localDate(od), + shipDate = localDate(sd); + if (!orderDate || !shipDate) return; + const days = Math.round((shipDate - orderDate) / 86400000); + const cls = classify(days, mode); + if (!cls) return; + const key = BUCKET[cls]; + + overall.total++; + overall[key]++; + + const m = + byMode[mode] || (byMode[mode] = { early: 0, onTime: 0, late: 0, total: 0, daysSum: 0 }); + m.total++; + m.daysSum += days; + m[key]++; + + const t = new Date(orderDate.getFullYear(), orderDate.getMonth(), 1).getTime(); + const mo = monthly[t] || (monthly[t] = { t, early: 0, onTime: 0, late: 0 }); + mo[key]++; + if (t < minT) minT = t; + if (t > maxT) maxT = t; + }); + + const months = Object.keys(monthly) + .map(Number) + .sort((a, b) => a - b) + .map((t) => monthly[t]); + let maxCount = 0; + months.forEach((mo) => { + const c = mo.early + mo.onTime + mo.late; + if (c > maxCount) maxCount = c; + }); + + return { + overall, + byMode, + months, + minT: isFinite(minT) ? minT : 0, + maxT: isFinite(maxT) ? maxT : 0, + maxCount, + }; + }); + } + + // ---- shared 3-segment Early/On Time/Late bar -------------------------------- + function PercentBar(seg, big, viz) { + if (!seg.total) return h('p', { class: 'muted' }, 'No data.'); + const parts = [ + { key: 'early', label: 'Shipped Early', count: seg.early }, + { key: 'onTime', label: 'Shipped On Time', count: seg.onTime }, + { key: 'late', label: 'Shipped Late', count: seg.late }, + ].filter((p) => p.count > 0); + + function over(e, p) { + const pct = p.count / seg.total; + viz.showTip( + frag( + h('div', { class: 'tip-head' }, p.label), + TipRow({ + colorVar: COLOR_VAR[p.key], + label: fmtInt.format(p.count) + ' orders', + value: fmtPct(pct, 0), + }), + ), + e.clientX, + e.clientY, + ); + } + + return h( + 'div', + { class: 'ship-bar' + (big ? ' ship-bar-big' : '') }, + parts.map((p) => { + const pct = p.count / seg.total; + return h( + 'div', + { + class: 'ship-bar-seg ship-seg-' + p.key, + style: { width: pct * 100 + '%' }, + onPointerEnter: (e) => over(e, p), + onPointerMove: (e) => over(e, p), + onPointerLeave: () => viz.hideTip(), + }, + big + ? h( + 'span', + { class: 'ship-bar-text' }, + h('strong', null, fmtPct(pct, 0)), + ' ' + p.label, + ) + : null, + ); + }), + ); + } + + // ---- Panel 1 trend chart: monthly stacked area of Early/On Time/Late counts - + const PAD_L = 44, + PAD_R = 10, + CHART_H = 160, + AXIS_H = 20; + + function bandPath(pts, topOf, bottomOf, xOf, yOf) { + let d = ''; + pts.forEach((p, i) => { + d += (i === 0 ? 'M' : 'L') + xOf(p.t).toFixed(1) + ' ' + yOf(topOf(p)).toFixed(1) + ' '; + }); + for (let i = pts.length - 1; i >= 0; i--) { + const p = pts[i]; + d += 'L' + xOf(p.t).toFixed(1) + ' ' + yOf(bottomOf(p)).toFixed(1) + ' '; + } + return (d + 'Z').trim(); + } + + function MonthlyArea(model, viz) { + const { months, minT, maxT, maxCount } = model; + const wrap = h('div', { class: 'ship-chart' }); + let svgEl, crosshair; + + function render(width) { + clear(wrap); + const cellW = Math.max(280, width || 600); + const plotW = cellW - PAD_L - PAD_R; + const step = niceStep(maxCount, 4) || 1; + const domainMax = Math.max(step, Math.ceil(maxCount / step) * step); + const yTicks = []; + for (let v = 0; v <= domainMax + 0.5; v += step) yTicks.push(v); + + const span = Math.max(1, maxT - minT); + const xOf = (t) => PAD_L + ((t - minT) / span) * plotW; + const innerH = CHART_H - AXIS_H - 4; + const yOf = (v) => 4 + innerH - (v / domainMax) * innerH; + + const earlyPath = bandPath( + months, + (p) => p.early, + () => 0, + xOf, + yOf, + ); + const onTimePath = bandPath( + months, + (p) => p.early + p.onTime, + (p) => p.early, + xOf, + yOf, + ); + const latePath = bandPath( + months, + (p) => p.early + p.onTime + p.late, + (p) => p.early + p.onTime, + xOf, + yOf, + ); + + const y0 = new Date(minT).getFullYear(); + const yN = new Date(maxT).getFullYear() + 1; + const years = []; + for (let yr = y0; yr <= yN; yr++) years.push(yr); + + crosshair = svg('line', { + class: 'crosshair', + x1: -10, + x2: -10, + y1: 4, + y2: 4 + innerH, + style: { display: 'none' }, + }); + + function nearest(clientX) { + const box = svgEl.getBoundingClientRect(); + const svgX = ((clientX - box.left) / box.width) * cellW; + let best = months[0], + bd = Infinity; + months.forEach((p) => { + const dx = Math.abs(xOf(p.t) - svgX); + if (dx < bd) { + bd = dx; + best = p; + } + }); + return best; + } + function move(e) { + if (!svgEl || !months.length) return; + const p = nearest(e.clientX); + const cx = xOf(p.t); + crosshair.setAttribute('x1', cx); + crosshair.setAttribute('x2', cx); + crosshair.style.display = ''; + const d = new Date(p.t); + const total = p.early + p.onTime + p.late; + viz.showTip( + frag( + h('div', { class: 'tip-head' }, `${MONTH_NAMES[d.getMonth()]} ${d.getFullYear()}`), + TipRow({ + colorVar: '--div-pos', + label: 'Shipped Early', + value: `${fmtInt.format(p.early)} (${fmtPct(total ? p.early / total : 0, 0)})`, + }), + TipRow({ + colorVar: '--div-mid', + label: 'Shipped On Time', + value: `${fmtInt.format(p.onTime)} (${fmtPct(total ? p.onTime / total : 0, 0)})`, + }), + TipRow({ + colorVar: '--div-neg', + label: 'Shipped Late', + value: `${fmtInt.format(p.late)} (${fmtPct(total ? p.late / total : 0, 0)})`, + }), + ), + e.clientX, + e.clientY, + ); + } + function leave() { + crosshair.style.display = 'none'; + viz.hideTip(); + } + + svgEl = svg( + 'svg', + { + class: 'ship-chart-svg', + width: cellW, + height: CHART_H, + viewBox: `0 0 ${cellW} ${CHART_H}`, + }, + yTicks.map((v) => { + const y = yOf(v); + return svg( + 'g', + null, + svg('line', { x1: PAD_L, y1: y, x2: PAD_L + plotW, y2: y, class: 'grid' }), + svg( + 'text', + { x: PAD_L - 6, y: y + 3, class: 'y-lbl', 'text-anchor': 'end' }, + fmtInt.format(v), + ), + ); + }), + svg('path', { d: earlyPath, class: 'ship-area-early' }), + svg('path', { d: onTimePath, class: 'ship-area-ontime' }), + svg('path', { d: latePath, class: 'ship-area-late' }), + crosshair, + years.map((yr) => { + const tx = xOf(new Date(yr, 0, 1).getTime()); + if (tx < PAD_L - 2 || tx > PAD_L + plotW + 2) return null; + return svg( + 'g', + null, + svg('line', { x1: tx, y1: 4 + innerH, x2: tx, y2: 4 + innerH + 4, class: 'grid' }), + svg('text', { x: tx, y: CHART_H - 4, class: 'x-lbl', 'text-anchor': 'middle' }, yr), + ); + }), + svg('rect', { + x: PAD_L, + y: 0, + width: plotW, + height: 4 + innerH, + fill: 'transparent', + class: 'hit', + onPointerMove: move, + onPointerLeave: leave, + }), + ); + wrap.appendChild(svgEl); + } + + const disconnect = observeWidth(wrap, render); + return { el: wrap, disconnect }; + } + + // ---- Panel 2: per-Ship-Mode breakdown --------------------------------------- + function ShipModeRows(byMode, viz) { + const order = SHIP_MODE_ORDER.filter((m) => byMode[m]); + Object.keys(byMode).forEach((m) => { + if (order.indexOf(m) < 0) order.push(m); + }); + + return h( + 'div', + { class: 'ship-modes' }, + order.map((mode) => { + const seg = byMode[mode]; + const avgDays = seg.total ? seg.daysSum / seg.total : 0; + return h( + 'div', + { class: 'ship-mode-row' }, + h('div', { class: 'ship-mode-name' }, mode), + PercentBar(seg, false, viz), + h( + 'div', + { class: 'ship-mode-meta' }, + `${fmtInt.format(seg.total)} orders · avg ${avgDays.toFixed(1)}d to ship`, + ), + ); + }), + ); + } + + function Legend() { + return h( + 'div', + { class: 'series-legend' }, + h( + 'span', + { class: 'series-item' }, + h('span', { class: 'series-swatch', style: { background: 'var(--div-pos)' } }), + 'Early', + ), + h( + 'span', + { class: 'series-item' }, + h('span', { class: 'series-swatch', style: { background: 'var(--div-mid)' } }), + 'On Time', + ), + h( + 'span', + { class: 'series-item' }, + h('span', { class: 'series-swatch', style: { background: 'var(--div-neg)' } }), + 'Late', + ), + ); + } + + const muted = (t) => h('p', { class: 'muted' }, t); + const errP = (t) => h('p', { class: 'error' }, t); + function setBody(slot, node) { + clear(slot); + slot.appendChild(node); + } + + function ShippingView({ ds, viz }) { + let region = null, + dateSel = null; + let token = 0; + let areaDc = null; + + const trendsSlot = h('div', null, muted('Loading…')); + const modesSlot = h('div', null, muted('Loading…')); + + const el = h( + 'div', + { style: { display: 'contents' } }, + h( + 'section', + { class: 'card' }, + h( + 'div', + { class: 'panel-head' }, + h('h2', { class: 'card-title' }, 'On-Time Shipment Trends'), + Legend(), + ), + trendsSlot, + ), + h( + 'section', + { class: 'card' }, + h('h2', { class: 'card-title' }, 'On-Time Performance by Ship Mode'), + modesSlot, + ), + ); + + function clearArea() { + if (areaDc) { + areaDc(); + areaDc = null; + } + } + + function load() { + const my = ++token; + viz.beginLoad(); + fetchShipping(ds, { region, dateSel }) + .then((data) => { + if (my !== token) return; + clearArea(); + if (!data.overall.total) { + setBody(trendsSlot, muted('No data in current scope.')); + setBody(modesSlot, muted('No data in current scope.')); + return; + } + const area = MonthlyArea(data, viz); + areaDc = area.disconnect; + const trends = frag(PercentBar(data.overall, true, viz), area.el); + setBody(trendsSlot, trends); + setBody(modesSlot, ShipModeRows(data.byMode, viz)); + }) + .catch((e) => { + if (my !== token) return; + clearArea(); + const msg = 'Could not load shipments: ' + errMsg(e); + setBody(trendsSlot, errP(msg)); + setBody(modesSlot, errP(msg)); + }) + .then(() => viz.endLoad()); + } + + return { + el, + update({ region: r, dateSel: d }) { + region = r ?? null; + dateSel = d ?? null; + load(); + }, + destroy() { + token++; + clearArea(); + }, + }; + } + + Object.assign(SV, { ShippingView }); +})((window.SV = window.SV || {})); diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/Tooltip.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/Tooltip.js new file mode 100644 index 00000000..93886984 --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/components/Tooltip.js @@ -0,0 +1,50 @@ +(function (SV) { + 'use strict'; + var { h } = SV; + // One tooltip row: colored key swatch (optional), the value (leads, strong), + // then the label. Returns a DOM node. + function TipRow({ colorVar, label, value }) { + return h( + 'div', + { class: 'tip-row' }, + h('span', { class: 'tip-key', style: colorVar ? { background: `var(${colorVar})` } : null }), + h('span', { class: 'tip-val' }, value), + h('span', { class: 'tip-lbl' }, label), + ); + } + + // The shared floating tooltip. Owns a single persistent .tip element appended to + // .viz-root and exposes show(content, clientX, clientY) / hide(). content is a + // DOM node (or fragment). Driven imperatively on pointermove so hovering never + // rebuilds the map/panels. Positioning: offset from the cursor, clamped inside + // .viz-root. + function createTooltip(vizRootEl) { + const el = h('div', { class: 'tip', hidden: true }); + vizRootEl.appendChild(el); + + return { + el, + show(content, clientX, clientY) { + while (el.firstChild) el.removeChild(el.firstChild); + if (content) el.appendChild(content); + el.hidden = false; // unhide before measuring offsetW/H + const rect = vizRootEl.getBoundingClientRect(); + let x = clientX - rect.left + 14; + let y = clientY - rect.top + 14; + const tw = el.offsetWidth; + const th = el.offsetHeight; + if (x + tw > rect.width) x = clientX - rect.left - tw - 14; + if (y + th > rect.height) y = rect.height - th - 4; + if (x < 0) x = 4; + if (y < 0) y = 4; + el.style.left = x + 'px'; + el.style.top = y + 'px'; + }, + hide() { + el.hidden = true; + }, + }; + } + + Object.assign(SV, { TipRow, createTooltip }); +})((window.SV = window.SV || {})); diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/data/geo-states.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/data/geo-states.js new file mode 100644 index 00000000..2e6a19dc --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/data/geo-states.js @@ -0,0 +1,6 @@ +// Pre-projected Albers-USA state paths, assigned to SV.GEO so the no-build app +// reads them as a classic script. Generated data: do not edit by hand. +// Derived from us-atlas by Mike Bostock (ISC License), which is built from the +// U.S. Census Bureau's cartographic boundary files. +window.SV = window.SV || {}; +SV.GEO = {"viewBox":"-62 9 1023 602","states":[{"name":"Alabama","code":"AL","d":"M648 492L652 492L652 491L653 492L652 492L652 492L649 493L648 492ZM642 384L642 384L656 383L667 382L685 380L689 380L690 382L693 394L703 427L703 427L703 428L704 429L705 432L705 433L707 434L707 436L708 437L707 439L709 439L710 440L709 441L708 442L707 443L707 444L707 446L707 448L706 450L707 453L707 453L708 455L709 457L708 458L708 459L708 461L708 462L708 463L708 465L710 466L711 469L694 471L682 472L670 473L661 474L661 475L660 477L662 479L663 480L665 481L665 482L665 485L665 486L666 486L665 487L665 489L664 489L665 490L664 490L661 491L658 492L655 492L656 491L657 492L659 491L659 490L657 488L656 488L655 486L656 485L655 483L655 482L653 482L652 483L653 483L652 486L652 489L652 490L651 490L651 490L649 489L648 489L648 489L647 490L645 478L643 460L642 455L643 444L643 413L643 396L644 386L643 386L642 384Z"},{"name":"Alaska","code":"AK","d":"M104 552L105 550L105 551L104 552ZM99 570L100 569L100 570L100 569L102 567L102 567L102 566L103 565L103 568L104 567L104 568L105 568L105 569L104 569L104 569L103 569L102 570L101 571L99 570ZM95 582L96 581L97 582L96 582L95 582ZM93 575L94 574L96 573L97 573L97 574L97 573L97 572L98 571L99 572L100 572L99 571L101 572L100 571L101 571L101 572L102 572L103 571L104 572L103 572L103 574L105 573L104 575L102 574L103 575L102 576L100 577L102 577L100 578L100 578L99 578L100 577L98 579L97 580L96 580L97 579L97 579L98 577L95 580L94 578L94 577L93 576L93 575ZM93 583L94 582L95 582L93 583L93 583ZM89 588L90 587L90 588L89 588ZM72 594L72 593L72 594L72 594ZM71 592L72 591L72 593L71 593L71 592ZM68 594L69 593L69 592L69 591L70 592L70 592L68 594ZM66 590L67 590L68 590L68 591L67 590L67 592L66 591L66 592L66 590ZM68 564L68 563L69 563L68 564L68 564ZM61 591L61 591L62 592L62 592L61 591ZM58 592L59 592L59 593L58 592ZM66 524L67 524L67 524L66 524ZM56 596L56 596L58 597L58 597L56 596ZM45 597L46 597L46 597L45 597ZM43 596L43 595L44 596L43 597L43 596ZM41 596L42 596L43 597L41 597L41 596ZM44 548L46 548L47 547L49 547L50 547L50 548L50 548L51 549L51 551L49 551L48 552L48 551L47 551L44 549L44 548ZM31 601L33 600L34 600L36 599L37 599L37 599L36 598L35 598L36 597L38 597L38 597L39 598L39 597L40 598L38 599L40 598L40 599L39 599L38 600L37 601L35 601L34 601L32 602L31 601ZM51 505L53 504L56 502L59 501L61 500L64 500L66 500L66 501L65 503L65 504L67 504L69 504L70 505L71 505L71 505L73 503L74 504L74 503L73 502L71 503L72 501L71 499L70 499L70 498L71 498L72 499L71 500L73 502L73 502L72 500L72 498L73 498L72 497L70 497L67 496L67 495L66 492L63 488L61 487L61 486L59 484L60 484L61 483L62 481L64 481L67 481L70 480L71 479L71 477L72 475L74 472L75 471L77 472L78 471L80 470L82 468L83 468L84 469L87 468L88 467L89 465L90 465L93 466L93 467L91 468L92 469L92 469L93 468L94 467L94 466L95 467L95 469L96 469L97 468L99 468L101 469L101 470L101 470L103 471L102 472L104 472L105 472L106 471L107 472L109 471L111 472L111 471L112 472L112 472L114 473L115 473L116 473L119 473L120 473L122 474L123 474L124 472L126 472L132 474L133 474L147 547L149 547L149 547L151 547L152 546L155 545L155 547L155 548L157 548L157 549L162 552L164 555L166 552L167 552L167 551L166 550L167 549L167 548L168 547L169 546L172 547L172 548L173 549L174 549L175 550L176 552L178 552L181 554L181 554L182 556L183 557L184 558L186 560L188 562L188 563L190 563L190 564L191 564L192 566L193 565L195 566L197 566L198 566L199 567L200 567L201 568L201 569L202 571L203 573L202 576L201 578L201 577L200 578L198 575L199 574L199 572L198 571L197 570L198 572L198 574L198 575L197 575L195 575L194 574L195 573L194 571L194 573L194 573L194 574L193 574L192 573L193 572L192 570L192 569L192 570L192 571L191 572L190 571L189 571L189 570L189 569L190 568L190 567L190 567L189 566L188 565L187 565L186 564L183 564L183 562L182 560L182 560L181 560L184 560L182 559L181 559L179 558L178 556L178 557L176 557L175 555L173 553L172 550L170 550L170 550L171 551L172 552L173 555L175 558L173 557L173 557L172 557L171 557L170 555L169 554L169 555L168 554L167 553L167 554L166 554L166 554L167 554L168 555L169 555L170 557L169 558L168 558L168 559L167 558L166 558L164 557L162 556L162 556L160 555L159 554L157 554L155 553L153 552L153 551L154 551L153 549L153 548L153 550L151 551L149 551L146 551L147 550L146 550L142 550L141 550L135 552L134 553L134 552L135 551L136 551L135 550L132 550L133 550L132 550L132 549L131 550L129 549L128 549L129 548L127 549L128 550L125 551L125 549L126 549L128 548L128 547L127 548L127 547L126 547L125 547L127 546L125 546L125 545L125 544L124 546L123 544L123 546L122 546L122 546L121 547L119 547L120 545L119 545L118 547L119 548L119 550L120 549L120 550L120 552L121 553L120 554L119 554L119 552L119 554L118 554L116 554L115 555L115 554L115 553L115 555L114 554L114 557L113 556L113 557L111 559L111 559L111 557L110 559L110 559L109 560L108 561L107 560L106 561L106 561L105 560L105 559L107 558L109 556L108 556L107 557L105 556L106 554L107 553L107 550L107 549L108 548L111 546L111 547L112 547L113 546L116 547L113 546L112 545L113 543L114 542L113 542L112 543L112 544L110 544L109 544L108 545L108 546L106 546L105 548L106 549L105 549L103 550L104 551L102 553L102 554L101 555L100 556L101 556L101 557L98 558L98 559L96 560L96 562L96 562L98 562L99 563L100 564L99 565L98 566L97 566L96 567L97 567L96 569L95 570L94 570L92 571L92 571L91 572L91 573L90 572L89 574L88 574L88 575L87 574L86 576L87 576L87 577L86 578L84 578L83 580L82 579L81 581L80 581L80 581L80 582L79 582L78 582L77 583L79 584L77 585L77 586L76 585L76 586L75 586L75 586L72 587L72 588L71 589L72 587L71 586L70 587L68 588L67 589L66 589L64 590L63 590L64 588L63 588L62 589L61 591L60 590L61 591L60 591L59 592L58 591L58 590L58 589L57 590L58 591L58 592L56 592L56 591L55 591L56 592L54 593L55 593L55 594L54 593L53 594L51 594L49 594L49 595L48 595L47 595L47 594L48 593L49 592L52 591L53 591L54 592L55 590L57 590L58 588L60 587L62 585L65 585L67 585L66 586L66 587L67 586L68 586L69 587L69 586L68 585L69 583L71 581L73 580L75 579L76 579L77 578L78 576L79 575L81 573L82 569L83 569L82 568L82 566L84 564L84 563L84 563L80 565L78 564L78 562L77 563L77 564L77 566L76 567L76 566L75 563L73 562L73 563L72 562L71 562L71 561L69 562L67 563L66 563L65 564L64 563L65 562L65 561L64 558L65 558L66 557L65 553L64 551L63 551L63 552L62 552L61 553L58 553L57 553L57 551L56 551L55 549L54 548L53 547L54 547L53 547L54 545L55 544L54 543L55 543L54 541L53 542L53 542L53 541L52 540L52 539L53 539L52 538L51 538L51 537L53 537L52 536L53 536L53 535L54 534L58 530L58 529L59 527L60 526L61 526L62 526L63 528L64 528L66 527L68 525L68 525L70 526L72 525L73 523L73 521L73 520L72 518L71 518L72 517L73 517L74 517L74 516L73 514L72 515L71 515L69 516L68 517L67 518L67 517L66 515L65 516L66 517L65 517L64 516L61 516L59 516L55 514L55 513L55 512L54 511L54 509L55 510L56 509L55 508L52 507L51 505L51 505ZM25 603L27 602L27 601L28 600L29 601L29 600L29 599L31 599L32 599L31 600L29 601L28 602L25 603L25 603ZM29 573L30 574L29 574L29 573ZM20 602L22 602L22 603L21 603L20 602ZM27 568L28 568L27 569L27 568ZM16 603L17 603L17 603L16 604L16 603ZM34 518L35 516L36 517L38 519L39 518L41 519L41 520L42 521L45 522L44 524L42 523L41 525L40 523L39 521L38 520L37 519L35 520L34 519L34 518ZM7 603L8 603L9 603L8 604L7 603ZM23 540L24 539L24 540L26 542L24 541L23 540ZM0 602L1 603L2 603L3 604L5 604L3 604L0 603L0 602ZM-6 601L-5 601L-1 601L-1 601L0 600L1 601L-1 602L0 602L-2 603L-3 602L-6 601ZM-10 599L-9 600L-10 601L-10 600L-10 599ZM-11 601L-10 601L-10 602L-11 602L-11 601ZM-15 601L-14 600L-13 599L-12 600L-12 601L-11 601L-11 602L-12 601L-14 602L-15 601ZM-18 600L-16 600L-15 599L-15 599L-15 601L-16 601L-17 600L-18 600ZM-20 598L-19 598L-18 599L-19 600L-20 599L-19 599L-20 598ZM-30 593L-29 593L-29 593L-30 594L-30 593ZM-35 593L-34 594L-33 596L-34 595L-35 593ZM-41 589L-39 589L-38 588L-38 588L-40 589L-41 589ZM-56 577L-54 577L-54 578L-56 577ZM-58 571L-57 571L-55 572L-54 574L-55 573L-55 573L-56 573L-57 571L-58 571ZM197 577L198 577L199 577L198 578L197 577ZM197 577L197 576L196 575L197 575L198 577L197 577ZM195 575L195 574L196 575L196 577L195 575ZM188 570L188 570L189 571L188 570ZM187 569L187 568L189 568L189 569L188 570L187 569ZM186 577L186 576L188 576L188 576L188 577L187 578L187 577L186 577ZM185 573L186 571L185 571L185 570L187 570L189 571L190 572L191 573L193 575L191 574L191 575L192 575L193 575L194 577L194 576L195 576L195 578L195 578L196 579L196 580L194 580L193 580L194 579L193 579L192 577L192 578L192 579L191 579L191 578L190 578L192 580L192 579L193 580L192 580L193 581L192 581L190 580L189 578L188 578L188 578L189 576L188 575L186 574L186 574L186 572L186 574L185 573ZM183 574L184 574L183 575L183 574ZM180 567L181 566L182 566L182 567L182 566L181 565L182 565L184 565L185 564L187 566L189 567L188 568L186 568L186 569L185 569L184 569L184 569L184 572L183 573L183 573L182 571L182 569L181 569L180 567ZM175 556L176 557L176 558L177 557L178 557L178 558L180 559L181 561L180 559L179 559L179 560L181 561L181 563L180 566L179 566L179 565L179 563L178 562L177 561L177 560L175 557L175 556ZM174 565L175 563L176 564L177 563L178 565L180 568L181 572L181 573L180 572L178 569L176 569L176 568L176 567L175 567L176 565L175 566L174 565ZM174 566L174 565L175 566L175 567L174 568L174 567L174 566ZM169 561L169 560L170 560L169 559L170 559L171 559L172 558L173 559L175 559L176 560L175 560L176 560L177 563L176 563L174 562L174 564L174 565L173 565L172 564L171 564L170 562L169 561ZM121 555L121 554L123 552L124 550L124 551L122 554L123 554L121 555ZM120 551L121 550L122 549L122 550L121 552L120 551Z"},{"name":"Arizona","code":"AZ","d":"M145 407L146 407L147 407L149 407L149 406L150 405L151 404L150 401L149 401L147 400L147 399L148 397L148 395L148 394L148 393L148 392L150 392L150 391L152 389L152 389L153 386L154 386L154 382L154 382L154 381L156 379L157 377L159 377L160 376L162 375L163 374L162 373L161 372L160 370L159 369L159 368L159 366L159 366L159 363L157 362L157 360L156 359L157 356L158 355L157 354L158 354L159 353L159 349L159 347L158 345L159 342L159 341L159 340L159 339L159 338L160 337L160 337L159 335L160 333L160 331L160 331L162 331L163 331L164 331L164 331L167 332L168 333L167 334L168 335L170 335L172 332L173 332L174 327L175 321L176 313L185 315L203 318L223 322L239 324L239 324L254 327L265 328L260 365L258 379L253 413L251 424L247 456L229 454L220 452L208 451L176 432L172 430L142 412L143 411L143 409L144 408L145 407Z"},{"name":"Arkansas","code":"AR","d":"M523 355L536 355L550 355L565 354L586 353L604 352L604 354L605 354L605 355L605 357L605 358L603 360L603 361L601 362L600 364L612 363L612 364L614 365L613 366L612 365L612 366L612 366L613 367L611 368L611 369L610 369L609 369L608 369L608 371L609 371L610 371L610 372L608 373L609 374L609 375L608 374L607 374L607 375L608 376L607 377L607 376L606 376L605 377L605 378L606 378L606 377L607 378L606 380L605 380L605 381L606 381L607 382L606 382L607 383L607 384L606 383L605 384L605 386L603 386L603 387L604 388L604 389L602 390L601 391L601 389L600 390L600 391L601 392L600 393L599 393L599 391L598 393L599 394L600 394L600 394L600 395L599 395L599 394L598 395L598 396L599 398L598 399L599 400L599 400L597 401L597 402L597 403L597 402L595 402L596 403L595 404L594 404L594 405L593 405L592 405L593 406L595 406L594 407L593 406L592 407L592 408L594 408L593 409L592 410L592 411L591 411L591 410L590 411L590 411L591 412L590 413L591 415L592 415L589 416L589 417L590 417L590 417L591 418L590 418L589 417L587 418L589 419L589 420L588 420L589 422L588 423L589 423L589 422L590 422L590 423L588 424L589 425L590 424L590 423L591 423L590 425L590 426L590 428L591 427L591 427L591 428L591 430L590 430L589 430L589 431L590 432L589 433L575 434L569 434L548 434L535 435L535 423L534 422L533 423L532 422L531 423L530 422L529 423L529 422L527 422L527 421L526 421L527 394L527 381L524 360L523 355Z"},{"name":"California","code":"CA","d":"M77 374L78 375L80 376L81 377L82 379L81 380L81 379L79 378L79 378L79 376L78 376L77 374ZM75 384L75 384L76 387L78 390L77 390L76 389L75 384ZM65 358L66 359L66 359L65 359L65 358ZM58 374L59 374L61 376L60 376L59 376L58 374ZM57 355L58 356L60 356L61 357L62 358L62 357L64 358L63 359L61 358L60 359L57 357L57 356L57 355ZM50 355L52 355L54 355L54 356L55 357L55 358L52 358L51 357L50 355ZM47 353L49 353L49 354L48 354L47 353ZM33 160L40 162L47 164L50 165L51 165L58 167L62 168L68 170L84 175L96 178L101 179L94 204L92 212L89 224L85 241L83 246L95 263L102 273L117 296L132 319L144 338L157 356L156 359L157 360L157 362L159 363L159 366L159 366L159 368L159 369L160 370L161 372L162 373L163 374L162 375L160 376L159 377L157 377L156 379L154 381L154 382L154 382L154 386L153 386L152 389L152 389L150 391L150 392L148 392L148 393L148 394L148 395L148 397L147 399L147 400L149 401L150 401L151 404L150 405L149 406L149 407L147 407L146 407L145 407L100 402L100 400L99 398L98 398L98 395L98 395L99 394L99 390L99 388L97 382L95 381L95 379L93 379L93 376L91 375L90 374L89 373L88 371L86 370L86 371L85 371L82 369L82 369L83 368L83 367L83 365L82 363L81 362L78 361L76 362L76 361L74 360L72 358L71 358L70 357L69 354L68 352L68 352L67 351L65 349L63 349L62 349L61 348L59 348L57 346L55 345L52 345L49 344L49 342L47 341L48 338L48 337L49 335L48 333L48 333L50 330L50 328L48 327L48 327L46 326L46 325L47 323L47 321L47 320L45 319L44 317L43 315L42 313L41 312L41 311L40 308L40 305L39 304L38 302L37 300L36 298L35 296L35 295L35 292L36 291L35 290L36 289L37 290L38 289L40 286L39 283L38 281L37 282L35 281L34 279L33 277L32 276L32 275L31 274L32 273L33 271L32 269L32 268L32 267L33 263L33 261L35 261L35 263L35 263L35 265L35 266L36 267L37 269L38 269L39 270L39 270L38 269L38 267L39 265L38 263L38 263L36 262L37 260L37 259L36 259L36 257L37 257L37 257L38 257L39 256L39 255L38 253L36 254L35 256L36 257L35 257L34 258L35 259L34 258L34 260L33 260L33 259L32 257L31 257L30 256L29 255L29 254L28 253L27 254L26 254L28 251L28 250L28 249L29 248L28 247L27 247L28 245L27 243L25 241L24 240L23 236L21 232L20 230L20 229L21 228L22 227L21 222L21 219L22 218L24 216L24 215L24 213L24 212L24 209L23 208L23 207L22 204L22 204L22 202L21 201L19 197L19 196L19 194L19 193L20 191L21 190L25 186L27 184L28 182L27 181L28 179L29 177L31 173L32 170L32 166L31 166L30 164L32 162L33 160Z"},{"name":"Colorado","code":"CO","d":"M277 237L286 238L301 240L321 243L325 243L333 244L348 245L351 246L361 247L371 247L382 248L395 249L394 272L392 300L391 317L391 327L390 341L378 340L373 339L349 338L337 337L335 336L315 334L303 333L303 333L293 332L279 330L265 328L266 319L266 316L268 302L268 299L270 289L271 280L276 249L276 246L277 237Z"},{"name":"Connecticut","code":"CT","d":"M871 184L879 182L882 182L882 183L883 182L883 181L899 178L899 178L901 186L902 191L902 191L902 193L902 194L900 194L899 194L898 195L897 195L897 195L896 196L894 197L892 197L891 198L890 198L889 198L889 199L887 198L885 199L885 198L884 201L883 201L882 203L881 202L880 204L879 204L878 205L878 206L876 206L875 208L875 207L874 208L874 208L872 206L876 203L874 201L873 194L871 184Z"},{"name":"Delaware","code":"DE","d":"M849 246L849 246L849 246L849 246ZM845 245L846 243L847 242L848 241L849 241L851 241L850 242L850 244L849 246L849 247L849 247L849 249L852 252L853 253L854 255L854 256L855 258L856 259L857 261L860 263L861 263L861 263L862 266L864 270L858 272L853 273L846 248L845 245Z"},{"name":"District of Columbia","code":"DC","d":"M826 267L827 265L830 267L828 270L828 269L827 268L826 267Z"},{"name":"Florida","code":"FL","d":"M815 595L816 593L816 594L815 595L815 595ZM813 596L813 595L814 596L813 596ZM807 599L811 597L812 597L809 599L807 599ZM794 604L795 602L798 600L799 598L801 598L804 599L804 600L805 600L802 602L802 601L800 602L800 602L796 604L794 605L794 604ZM790 605L792 604L792 606L790 606L790 605ZM786 606L787 605L788 605L788 606L786 606ZM777 558L778 559L780 562L782 563L782 563L783 563L782 564L780 563L778 559L777 558ZM770 606L771 606L772 606L770 607L770 606ZM720 495L722 494L722 495L721 496L720 495ZM711 469L711 470L712 471L713 473L713 474L714 475L741 473L748 473L766 472L766 472L766 473L766 475L767 476L770 476L770 473L770 471L769 469L769 466L770 466L770 465L771 465L772 465L773 465L775 466L776 466L778 466L779 466L780 466L781 466L781 469L781 470L782 471L783 476L785 482L787 485L787 486L790 491L793 496L795 500L801 508L805 511L806 514L805 515L805 517L806 520L807 522L808 525L812 529L814 534L816 537L818 541L818 541L820 544L822 549L823 554L823 557L823 561L823 565L824 571L824 575L823 573L823 573L822 574L822 576L821 576L821 577L821 578L821 580L821 581L822 581L821 582L821 583L821 584L822 583L824 578L824 580L822 585L822 587L818 592L816 593L816 593L819 589L820 588L820 586L818 586L815 587L814 588L812 587L811 588L811 589L807 590L806 589L805 588L805 585L805 585L804 582L803 582L803 581L802 580L801 579L801 578L799 578L798 576L797 576L793 574L792 575L790 573L789 571L788 566L787 564L786 563L783 562L783 561L782 562L782 562L781 563L780 559L779 558L780 558L781 559L781 555L781 554L780 554L780 552L780 552L779 553L780 556L778 557L777 558L777 556L777 556L775 554L773 551L772 549L769 546L767 543L765 541L766 542L767 541L768 539L770 536L771 535L771 533L771 532L769 532L770 534L768 534L768 533L768 531L765 530L765 531L764 532L765 532L766 532L767 534L767 535L767 537L765 537L766 539L765 539L765 537L763 535L763 535L762 533L762 530L761 529L761 526L761 526L762 528L762 529L763 530L763 528L762 527L763 526L764 521L764 517L763 516L763 515L762 514L762 514L761 512L762 510L760 508L760 508L760 507L759 507L759 505L758 505L755 505L754 506L753 504L753 503L751 503L751 502L750 500L748 500L748 499L746 498L746 496L745 495L745 495L743 494L742 493L742 492L740 491L739 490L737 489L734 488L732 487L730 487L730 488L727 487L728 488L726 490L727 492L726 492L725 492L724 492L723 492L720 495L720 495L716 497L717 496L716 496L714 498L712 498L713 499L713 500L714 500L718 498L720 496L720 496L719 498L714 501L710 499L709 499L707 500L706 498L706 496L706 495L707 497L707 499L708 499L708 498L708 497L707 494L706 494L704 493L703 493L702 491L700 491L698 490L696 488L693 487L689 486L686 486L681 486L678 486L675 487L671 488L669 489L668 489L664 490L665 490L664 489L665 489L665 487L666 486L665 486L665 485L665 482L665 481L663 480L662 479L660 477L661 475L661 474L670 473L682 472L694 471L711 469Z"},{"name":"Georgia","code":"GA","d":"M689 380L693 379L701 378L713 377L726 375L726 375L735 374L735 375L733 377L733 378L732 379L731 380L732 382L734 383L735 383L737 385L738 385L740 385L741 385L742 387L743 388L744 389L744 391L746 392L746 394L747 394L748 396L749 396L751 398L753 398L755 400L756 402L757 403L758 403L759 403L760 405L762 406L762 408L762 409L764 409L764 410L765 411L765 411L766 412L767 413L769 413L770 414L771 414L771 416L772 417L772 417L773 418L773 419L774 420L774 421L774 422L774 423L775 424L777 424L779 426L779 427L781 430L780 431L781 432L781 433L783 434L784 433L785 435L786 435L787 435L787 436L786 437L785 437L785 438L786 438L785 439L783 439L783 440L784 440L784 441L783 442L781 442L782 443L783 443L783 445L782 446L781 447L781 447L782 447L782 447L781 451L781 453L781 454L781 455L780 456L780 456L780 459L778 460L780 460L780 460L780 462L780 464L780 465L780 466L779 466L778 466L776 466L775 466L773 465L772 465L771 465L770 465L770 466L769 466L769 469L770 471L770 473L770 476L767 476L766 475L766 473L766 472L766 472L748 473L741 473L714 475L713 474L713 473L712 471L711 470L711 469L710 466L708 465L708 463L708 462L708 461L708 459L708 458L709 457L708 455L707 453L707 453L706 450L707 448L707 446L707 444L707 443L708 442L709 441L710 440L709 439L707 439L708 437L707 436L707 434L705 433L705 432L704 429L703 428L703 427L703 427L693 394L690 382L689 380Z"},{"name":"Hawaii","code":"HI","d":"M305 585L306 584L307 582L308 582L309 580L310 579L310 578L310 577L309 575L309 574L309 573L310 573L312 573L312 574L314 575L315 576L316 576L318 576L322 578L325 581L326 582L326 585L328 584L329 586L328 587L330 588L332 589L332 590L329 593L325 595L324 595L322 595L320 597L319 597L318 598L317 598L316 599L316 600L315 602L314 603L313 602L311 601L309 601L308 599L309 594L308 591L308 590L307 588L305 585L305 585ZM292 558L292 556L294 555L295 556L296 559L298 558L300 557L302 558L302 558L304 559L304 560L307 561L307 562L305 564L304 565L303 564L300 566L299 566L297 565L297 561L296 561L295 561L293 560L292 559L292 558ZM292 567L294 565L295 566L295 567L294 567L292 567L292 567ZM284 558L286 558L287 558L289 559L289 560L289 561L287 562L286 562L285 559L284 558ZM279 554L280 552L280 551L285 552L286 551L286 552L288 552L290 552L291 553L291 554L289 555L288 555L283 554L280 554L279 554ZM258 543L261 543L264 540L265 540L266 542L267 544L267 546L269 547L269 546L270 546L270 547L270 548L271 549L270 550L269 550L268 550L266 549L264 549L262 549L261 548L260 547L260 546L259 545L259 544L258 543ZM226 533L226 532L227 531L227 530L231 528L232 529L233 528L234 528L235 529L237 530L237 531L236 532L236 534L235 536L233 536L233 536L230 536L229 534L227 534L226 533ZM216 537L217 536L219 534L219 533L220 533L220 536L218 536L217 538L216 537L216 537Z"},{"name":"Idaho","code":"ID","d":"M171 102L170 100L171 99L171 96L170 94L171 92L175 76L178 59L181 50L184 36L198 39L193 62L194 62L195 65L196 66L196 67L197 69L196 70L197 71L196 72L196 73L196 74L197 74L195 74L198 77L198 78L200 79L201 80L202 82L202 83L204 85L204 87L205 87L205 88L205 90L206 91L207 92L207 94L208 93L209 94L209 95L209 96L211 96L212 96L213 96L213 96L212 99L212 99L212 101L211 102L211 103L210 104L210 104L210 106L208 106L208 107L209 108L208 109L208 110L209 111L209 112L209 113L209 114L207 113L206 115L207 116L206 118L205 118L206 119L205 120L207 120L207 121L208 123L209 122L210 121L211 121L212 121L213 120L214 119L214 118L215 118L216 120L217 121L216 123L217 123L217 124L217 126L217 127L217 129L218 131L219 132L220 133L219 133L220 134L220 136L219 137L219 139L221 140L221 141L223 140L224 142L224 143L225 144L225 145L224 146L225 147L225 148L225 149L227 151L227 152L228 151L227 150L229 149L230 149L230 149L232 150L233 150L233 150L235 151L235 149L237 149L237 149L238 150L239 150L241 150L241 150L243 150L244 151L246 151L246 150L247 151L248 151L249 151L250 151L249 150L250 149L250 148L251 148L252 147L253 148L254 150L254 151L255 152L255 153L256 154L250 193L249 198L247 210L240 208L229 207L228 206L200 202L198 201L189 199L185 198L182 198L177 197L160 193L149 191L158 151L158 149L159 149L159 148L160 146L160 145L161 144L162 143L161 142L161 142L161 141L160 141L159 140L158 140L157 139L158 138L157 137L158 136L158 135L160 134L160 133L162 130L164 129L167 126L166 125L167 124L168 124L169 122L171 118L173 116L174 115L174 114L176 112L175 111L175 109L174 108L173 108L173 107L172 107L171 104L171 102Z"},{"name":"Illinois","code":"IL","d":"M577 265L577 264L577 262L577 261L577 259L579 259L580 259L581 258L582 256L582 255L583 252L584 252L584 249L584 248L583 247L582 246L581 245L581 243L582 241L583 240L584 240L585 240L587 240L588 239L589 238L591 238L592 237L593 236L593 234L594 233L594 232L596 231L596 229L596 227L596 226L596 226L596 224L595 223L592 222L591 220L591 219L590 218L589 218L588 217L587 216L587 215L596 215L610 214L618 214L629 213L634 212L634 215L634 217L635 219L636 221L637 222L637 223L639 226L639 227L640 230L641 239L643 258L644 273L645 284L644 284L644 286L645 287L644 288L644 289L645 290L645 292L646 293L646 295L647 297L647 299L645 299L645 300L644 302L645 303L643 304L643 305L643 306L642 307L642 309L641 308L641 309L640 308L639 310L641 312L640 312L639 313L640 313L640 314L639 314L639 316L639 317L640 318L638 317L639 320L639 321L638 322L637 323L638 325L639 326L639 327L636 328L634 329L633 329L632 330L632 332L633 334L634 335L633 337L632 337L630 336L628 336L627 335L624 334L623 334L622 336L621 337L621 338L620 339L621 340L620 340L620 339L619 338L618 338L619 339L619 340L617 339L617 338L615 335L615 334L614 333L614 332L615 332L615 331L615 330L614 328L613 327L614 326L613 326L613 324L612 324L611 323L610 323L611 322L609 321L608 321L607 320L606 321L605 320L605 320L606 319L604 319L603 317L603 318L602 316L601 316L600 315L599 314L598 313L597 312L598 309L598 308L599 306L600 304L600 303L599 301L601 300L601 298L599 297L598 297L595 296L593 298L593 299L591 297L591 296L590 294L591 294L590 290L588 288L584 286L583 284L582 284L581 282L580 282L578 280L578 278L577 277L577 276L577 275L576 273L575 271L575 268L576 266L577 265Z"},{"name":"Indiana","code":"IN","d":"M640 230L641 231L643 230L642 231L645 231L647 231L650 229L652 228L669 226L686 224L686 225L689 247L690 257L691 268L693 284L691 286L693 287L693 288L692 289L694 289L693 290L694 292L692 292L691 292L688 295L686 294L685 294L683 294L683 295L684 298L682 300L681 301L680 304L679 305L678 305L677 305L677 306L676 308L676 310L676 311L676 312L674 312L674 313L673 312L672 312L670 311L670 309L668 308L668 309L669 309L667 310L666 310L667 311L667 311L666 312L666 313L666 314L665 315L664 316L663 316L664 315L662 316L662 315L661 313L660 314L659 315L657 316L656 318L655 319L655 317L654 317L651 316L649 316L648 317L647 315L646 316L647 317L647 318L646 319L645 317L643 318L641 317L641 318L642 319L641 320L639 320L638 317L640 318L639 317L639 316L639 314L640 314L640 313L639 313L640 312L641 312L639 310L640 308L641 309L641 308L642 309L642 307L643 306L643 305L643 304L645 303L644 302L645 300L645 299L647 299L647 297L646 295L646 293L645 292L645 290L644 289L644 288L645 287L644 286L644 284L645 284L644 273L643 258L641 239L640 230Z"},{"name":"Iowa","code":"IA","d":"M491 195L502 195L516 195L534 195L549 194L568 194L576 193L576 194L577 195L577 196L578 197L579 199L577 202L578 204L578 206L578 207L579 207L580 211L582 212L586 212L587 214L587 215L587 216L588 217L589 218L590 218L591 219L591 220L592 222L595 223L596 224L596 226L596 226L596 227L596 229L596 231L594 232L594 233L593 234L593 236L592 237L591 238L589 238L588 239L587 240L585 240L584 240L583 240L582 241L581 243L581 245L582 246L583 247L584 248L584 249L584 252L583 252L582 255L582 256L581 258L580 259L579 259L577 259L577 261L577 262L577 264L577 265L575 265L575 264L575 263L574 263L573 262L573 261L572 261L571 260L565 260L553 261L545 261L540 261L531 262L521 262L502 262L502 261L502 260L501 260L500 259L501 258L501 255L501 255L501 254L501 253L500 252L500 250L500 249L501 248L500 248L499 246L500 246L500 245L499 245L499 242L498 242L498 241L498 240L497 241L496 240L497 239L496 238L496 238L496 237L497 234L496 234L495 232L496 230L494 230L494 229L494 229L494 227L492 226L492 225L493 224L492 222L491 222L491 220L492 219L491 218L491 218L490 218L490 217L489 215L488 213L488 212L489 210L489 208L490 208L490 206L490 205L491 204L490 203L490 202L489 202L489 200L489 200L490 198L488 197L488 195L491 195Z"},{"name":"Kansas","code":"KS","d":"M394 272L405 273L422 274L432 274L443 274L454 275L465 275L480 275L495 275L510 275L512 277L513 278L513 278L515 278L515 278L517 278L516 278L517 279L517 280L517 281L518 280L517 281L516 281L516 282L515 283L514 285L514 285L514 286L515 287L515 287L516 288L517 289L517 289L517 290L517 291L518 292L519 293L519 293L520 294L521 294L521 294L523 294L522 295L522 307L523 322L523 326L523 344L507 344L489 344L472 344L459 344L443 343L433 343L429 343L414 342L402 341L390 341L391 327L391 317L392 300L394 272Z"},{"name":"Kentucky","code":"KY","d":"M621 340L620 339L621 338L621 337L622 336L623 334L624 334L627 335L628 336L630 336L632 337L633 337L634 335L633 334L632 332L632 330L633 329L634 329L636 328L639 327L639 326L638 325L637 323L638 322L639 321L639 320L641 320L642 319L641 318L641 317L643 318L645 317L646 319L647 318L647 317L646 316L647 315L648 317L649 316L651 316L654 317L655 317L655 319L656 318L657 316L659 315L660 314L661 313L662 315L662 316L664 315L663 316L664 316L665 315L666 314L666 313L666 312L667 311L667 311L666 310L667 310L669 309L668 309L668 308L670 309L670 311L672 312L673 312L674 313L674 312L676 312L676 311L676 310L676 308L677 306L677 305L678 305L679 305L680 304L681 301L682 300L684 298L683 295L683 294L685 294L686 294L688 295L691 292L692 292L694 292L693 290L694 289L692 289L693 288L693 287L691 286L693 284L694 283L695 284L696 285L698 284L699 283L699 284L699 285L701 285L702 286L702 287L703 288L704 290L707 290L708 290L710 290L711 291L712 291L712 292L714 293L715 291L716 291L717 291L719 291L721 292L721 292L723 292L723 290L724 290L725 289L726 289L727 288L728 292L729 292L731 292L733 294L733 295L733 297L734 297L734 298L734 299L734 301L733 301L734 302L735 303L736 304L737 304L736 306L737 306L738 307L738 307L740 309L741 311L742 311L743 312L744 312L744 313L747 313L741 320L740 321L738 322L735 325L735 326L734 327L733 328L733 329L733 330L732 331L730 332L729 333L729 334L728 334L726 335L724 337L722 337L720 338L720 339L720 339L714 340L704 341L699 341L696 341L693 342L687 342L681 343L678 343L672 343L669 343L668 344L667 344L658 345L652 345L645 346L645 345L641 345L642 349L641 350L627 351L619 351L617 351L617 349L618 348L620 350L620 350L621 348L621 347L620 346L621 345L622 345L621 344L621 343L622 340L621 340ZM616 352L615 352L614 350L614 350L616 350L616 351L616 352Z"},{"name":"Louisiana","code":"LA","d":"M638 497L639 499L639 500L639 503L638 504L639 501L639 499L638 497ZM629 498L631 495L631 497L632 498L631 499L631 498L629 498L629 498ZM624 510L625 510L624 511L624 510ZM610 521L610 520L611 521L610 522L610 521ZM606 521L609 522L607 522L606 521ZM602 522L604 522L604 522L602 523L602 522ZM577 512L578 511L580 511L583 512L582 513L582 514L580 514L576 512L577 512ZM535 435L548 434L569 434L575 434L589 433L589 435L590 435L590 435L590 434L591 434L591 436L590 437L590 437L590 439L592 439L591 440L590 441L591 443L592 442L593 441L593 442L592 443L593 445L593 445L591 444L591 445L593 446L593 446L594 446L593 447L594 448L595 448L595 449L594 449L594 450L594 451L593 450L590 452L591 453L593 453L593 452L593 452L593 454L592 455L591 454L591 455L592 456L590 457L590 458L589 459L587 460L587 461L589 461L589 462L587 462L586 463L587 465L586 465L585 464L584 465L585 466L586 465L586 466L586 467L584 467L585 469L585 470L585 471L585 471L584 469L583 470L585 472L585 473L584 473L582 473L582 474L583 475L583 476L584 478L584 478L583 479L594 479L606 478L619 477L620 477L619 478L618 482L618 483L618 484L618 485L618 486L619 487L620 488L621 490L621 491L622 492L622 493L623 495L624 496L625 495L624 496L623 496L622 497L622 498L621 498L621 499L619 499L619 500L619 501L620 501L621 501L621 502L622 503L624 502L624 501L624 500L626 499L626 498L628 499L627 499L627 500L628 500L628 501L629 501L630 499L631 500L631 501L630 501L629 502L631 502L631 502L628 503L629 504L630 504L630 505L628 504L627 505L627 507L627 507L626 506L625 506L625 508L623 508L624 508L624 509L623 508L623 509L623 510L624 511L625 511L626 512L626 513L629 513L630 514L630 513L632 515L632 514L634 516L635 517L636 517L637 517L635 518L635 518L636 518L636 519L635 519L634 520L634 522L633 521L633 521L632 520L630 523L629 524L630 521L631 520L631 519L631 519L631 518L630 517L630 519L629 519L627 517L625 517L624 516L620 515L619 516L616 519L613 521L612 520L611 520L612 519L611 517L610 516L609 518L609 517L608 515L607 517L606 517L606 517L605 517L605 518L604 519L604 520L604 520L603 521L603 521L601 522L601 523L600 523L600 522L598 520L597 520L595 520L595 519L593 519L592 519L590 518L591 517L592 516L593 517L594 517L594 518L595 518L594 516L593 515L593 514L592 514L591 515L590 515L590 515L590 515L590 513L590 513L588 514L587 512L586 512L586 510L584 510L584 510L584 508L582 508L580 509L579 509L579 508L580 507L580 507L580 506L579 506L578 506L577 506L577 507L575 508L574 508L573 508L574 509L575 509L574 510L575 511L576 511L576 511L576 512L574 512L572 513L571 513L565 512L562 511L558 510L557 509L554 508L551 508L550 509L548 508L543 509L541 510L539 508L540 506L542 504L543 502L543 501L543 500L543 499L543 497L542 496L542 495L543 493L543 492L542 491L542 490L543 490L543 489L544 488L544 486L546 485L545 483L546 482L545 481L546 480L546 479L545 476L544 477L544 474L543 474L543 472L542 471L541 470L542 469L540 468L540 467L540 465L540 464L540 463L539 462L538 460L537 460L536 458L536 458L535 435Z"},{"name":"Maine","code":"ME","d":"M940 115L941 114L940 115L940 115ZM938 116L938 115L940 116L939 117L938 116ZM936 112L937 111L937 113L936 112ZM932 116L933 116L933 117L932 116ZM934 125L934 124L934 125L934 125ZM931 119L932 118L933 117L933 118L934 119L934 120L933 121L932 121L932 119L931 119ZM930 116L931 115L930 113L931 113L931 116L930 116ZM910 150L908 149L908 147L905 145L904 144L903 140L903 140L901 135L898 123L895 114L891 102L892 102L892 101L893 102L894 103L895 103L895 101L895 100L894 99L895 98L896 99L897 99L897 98L895 97L895 96L896 94L897 92L899 90L898 89L899 87L900 86L900 85L899 85L899 84L898 84L899 83L898 83L899 81L898 80L898 78L898 77L898 76L899 76L899 74L900 74L899 68L906 48L908 48L909 48L910 50L910 52L913 52L915 50L916 50L917 49L917 49L917 48L920 48L919 46L920 46L923 46L923 46L927 48L928 49L930 50L933 60L935 67L937 74L938 74L937 75L939 77L938 77L939 80L940 80L940 79L942 81L944 80L945 80L946 82L945 83L945 84L946 84L947 86L946 87L947 88L948 89L950 90L950 89L950 88L951 88L952 88L954 90L955 92L956 93L957 95L955 99L954 100L953 99L952 100L952 101L952 102L951 102L951 103L950 103L950 104L951 104L950 106L949 105L949 104L948 105L947 104L947 106L946 106L946 108L945 108L945 109L944 108L944 110L943 110L943 109L941 110L942 111L942 113L942 113L940 114L939 114L937 112L938 111L939 110L938 110L938 111L937 110L935 111L936 112L936 113L937 114L937 115L936 117L936 118L937 119L937 120L937 120L936 119L936 118L935 117L934 116L935 115L933 114L933 114L932 114L931 112L932 111L931 110L930 112L930 112L929 113L930 114L930 115L929 116L929 117L929 118L929 119L929 121L930 120L930 121L930 122L930 123L929 123L928 125L928 125L928 126L927 127L927 125L926 125L926 126L926 124L925 125L925 126L924 127L924 128L924 127L924 128L923 129L922 128L922 130L921 129L921 131L920 132L919 131L918 131L917 133L917 133L916 134L915 134L915 135L915 137L915 138L914 137L913 138L913 139L913 140L913 142L913 142L911 143L911 146L911 146L911 148L911 150L910 150Z"},{"name":"Maryland","code":"MD","d":"M850 285L849 285L849 283L850 283L850 285ZM847 282L847 280L848 280L849 282L847 282ZM782 257L798 254L816 251L825 249L837 246L845 245L846 248L853 273L858 272L864 270L864 273L864 274L863 279L863 281L856 283L856 284L855 283L853 284L852 285L852 285L851 284L852 283L853 282L851 282L852 280L851 280L850 281L850 280L851 279L850 278L850 275L849 276L849 278L848 277L849 277L848 276L847 277L848 278L848 279L847 279L846 279L846 279L844 278L841 274L843 274L842 272L842 271L843 271L843 271L844 271L844 271L845 271L846 271L845 270L844 270L843 270L842 268L842 269L841 269L841 270L840 270L840 268L841 266L842 267L842 266L842 264L841 264L840 265L839 265L840 266L839 266L839 264L839 262L840 263L841 263L842 262L841 261L842 260L841 260L841 262L841 262L840 260L839 259L840 257L840 255L841 254L843 253L842 253L843 251L842 251L843 249L841 250L840 251L842 252L841 252L839 255L839 254L839 253L838 252L838 254L839 255L839 256L838 254L837 254L838 256L837 257L838 257L836 259L835 259L835 258L834 258L835 259L837 260L837 262L838 263L837 264L837 265L837 265L837 267L837 267L836 269L837 270L837 271L838 272L838 274L839 275L841 277L841 278L840 278L840 279L841 278L842 279L841 280L842 281L843 282L843 283L844 284L842 283L841 282L841 282L841 283L840 283L839 282L838 281L837 281L835 282L834 281L833 279L834 281L832 281L831 280L830 278L827 280L826 280L825 278L826 276L827 274L827 273L828 272L828 270L830 267L827 265L826 267L825 266L823 266L823 266L822 265L819 265L818 264L818 263L819 262L817 261L817 260L816 260L814 260L813 260L813 258L812 257L811 256L810 255L811 255L811 254L809 254L808 255L806 254L805 253L803 255L802 255L801 257L800 257L801 257L800 258L799 259L797 259L795 257L795 257L794 257L794 257L792 262L791 261L790 261L789 263L788 264L787 266L787 266L786 267L785 268L784 269L782 257Z"},{"name":"Massachusetts","code":"MA","d":"M928 187L929 187L930 186L931 186L932 184L933 186L932 187L931 188L928 187L928 187ZM918 188L919 188L920 186L921 184L922 185L923 186L924 186L924 185L925 187L920 188L920 189L918 188ZM871 168L884 165L892 163L903 161L903 159L905 159L904 158L905 157L906 157L906 156L908 155L909 155L910 155L911 159L913 159L913 158L914 158L914 160L913 160L911 162L911 162L911 163L911 164L910 165L910 166L910 167L910 167L910 169L911 169L911 168L912 167L913 168L914 168L915 169L915 170L917 171L917 173L916 173L918 174L919 174L920 176L920 177L922 178L923 178L925 178L926 178L926 177L929 175L929 175L928 172L927 173L927 171L926 170L925 170L925 170L924 170L924 169L927 170L928 171L930 174L931 177L931 179L931 180L930 178L930 178L927 179L926 180L925 180L923 181L923 182L921 183L919 186L916 188L916 187L918 186L920 183L920 182L919 180L920 180L918 179L918 181L918 180L918 182L917 182L917 182L915 183L915 185L914 186L913 186L912 183L911 183L910 182L909 181L908 181L907 179L906 179L906 176L899 178L899 178L883 181L883 182L882 183L882 182L879 182L871 184L871 183L871 168Z"},{"name":"Michigan","code":"MI","d":"M677 131L678 131L678 132L682 132L681 134L680 133L677 131ZM664 133L665 134L665 135L663 134L664 133ZM662 139L663 137L663 136L663 135L664 135L665 138L664 139L662 139ZM661 136L662 135L662 136L661 136ZM659 143L660 143L660 144L659 143ZM657 150L657 149L658 150L658 151L658 151L657 150L657 150ZM655 153L656 152L657 152L656 153L655 153ZM646 140L647 141L646 142L646 140ZM652 228L654 226L655 224L657 219L659 216L659 213L660 210L660 205L659 200L659 198L658 195L656 192L654 187L653 186L652 184L653 184L654 182L654 180L653 177L652 175L653 173L654 170L655 168L655 166L655 165L656 163L655 161L655 160L657 159L657 158L657 155L659 155L659 153L660 154L661 154L662 151L663 150L664 148L665 147L665 148L664 149L665 151L664 153L664 152L665 154L664 154L664 156L665 157L665 157L666 154L665 154L666 153L666 152L666 155L665 156L665 157L666 158L668 153L668 149L667 146L667 146L670 143L671 143L672 143L674 142L674 141L672 141L671 140L671 138L671 137L672 136L673 134L672 134L675 134L675 133L679 134L681 135L681 135L683 135L685 135L686 137L687 138L690 138L692 139L693 139L695 140L697 140L699 142L698 142L700 144L700 145L701 147L699 146L698 147L699 148L699 149L700 150L701 150L701 151L702 153L702 154L702 157L702 158L702 163L702 163L701 164L700 164L699 166L699 169L698 170L698 171L695 172L694 173L694 177L694 177L695 179L696 179L697 179L697 179L698 180L699 180L700 180L701 177L702 177L702 176L702 173L703 174L704 173L702 172L704 172L705 171L706 170L708 169L708 169L710 168L712 169L713 169L714 171L715 172L716 174L717 178L718 181L719 186L720 189L722 191L721 192L721 195L721 196L721 200L720 201L719 201L719 202L718 201L718 201L719 199L718 198L716 199L716 200L717 201L716 201L715 203L716 204L715 207L713 208L712 210L713 213L712 214L712 215L711 217L710 217L710 218L709 218L708 220L709 222L708 222L693 224L686 225L686 224L669 226L652 228ZM601 92L603 91L608 88L608 88L610 86L612 85L612 85L614 84L613 85L611 87L611 88L610 89L607 90L605 91L607 92L604 93L603 93L601 92ZM585 123L589 121L591 120L593 119L594 117L595 117L597 116L597 117L599 116L601 116L603 114L605 112L607 112L608 111L608 109L610 108L612 106L613 106L614 105L615 103L619 101L622 100L624 100L626 100L626 101L624 102L622 102L623 103L621 104L619 106L619 106L618 108L618 108L618 109L617 110L617 111L615 112L615 115L616 117L617 115L619 113L620 112L620 112L619 114L618 115L620 113L621 113L623 113L624 113L627 113L627 114L628 114L629 115L629 116L630 117L631 118L632 119L633 119L633 121L635 121L637 121L639 120L639 120L640 121L641 122L642 122L642 121L643 121L644 121L644 120L643 119L644 119L645 119L644 120L645 121L647 119L649 118L652 115L653 116L656 114L657 115L660 114L662 114L665 112L667 112L668 112L670 111L669 113L669 115L669 116L669 117L670 118L671 117L673 118L675 117L676 117L677 118L679 118L679 116L682 116L682 115L683 115L684 115L684 117L684 120L684 122L683 122L683 123L685 123L685 123L686 124L686 125L687 126L688 126L689 126L689 126L690 126L690 125L689 124L690 124L692 123L693 124L694 125L695 126L695 127L694 128L692 127L691 128L689 127L687 128L685 127L682 128L681 129L679 128L679 128L678 127L677 127L677 127L676 127L676 128L676 129L676 131L676 132L674 131L674 130L673 130L671 128L669 128L668 128L666 127L665 127L663 127L662 129L661 130L660 131L659 130L657 131L657 132L655 131L653 131L651 132L651 133L650 136L650 136L649 136L648 137L648 138L647 138L647 139L647 139L647 140L645 139L645 138L646 138L646 136L647 136L648 134L647 134L647 133L646 135L644 135L644 136L643 137L643 138L641 139L641 138L641 136L640 135L639 136L640 138L638 140L637 142L636 145L636 145L635 147L634 149L632 152L632 153L631 153L630 152L630 149L631 148L630 147L629 148L628 148L627 147L627 146L628 145L628 144L628 143L628 143L628 142L628 141L628 140L628 139L626 138L625 139L625 138L623 138L622 137L623 136L623 135L620 134L620 135L618 134L618 134L616 133L615 134L615 134L613 133L612 134L611 133L607 131L590 128L589 126L588 124L587 124L587 123L586 124L585 123Z"},{"name":"Minnesota","code":"MN","d":"M480 71L492 71L508 71L511 71L511 62L512 63L514 63L515 64L516 64L517 68L517 69L518 73L518 75L518 76L519 77L520 77L522 78L522 77L524 77L525 78L528 79L531 79L531 80L532 81L534 81L537 81L537 80L538 79L538 79L540 78L541 79L544 79L548 80L549 80L549 81L548 81L548 82L549 83L551 82L552 83L552 84L553 87L555 87L554 85L555 84L556 84L558 84L559 85L559 86L560 87L561 87L561 87L563 87L563 88L565 90L566 89L566 91L567 90L568 91L569 90L571 90L572 88L575 86L576 86L577 86L577 87L578 87L578 88L578 89L580 89L581 88L581 89L588 88L589 89L590 90L592 91L593 90L594 90L594 90L596 90L598 90L596 91L595 91L593 93L591 93L590 94L586 96L585 96L584 97L582 98L579 100L577 101L575 103L573 106L572 106L570 109L568 111L567 112L565 114L565 114L563 116L562 117L559 119L560 121L559 120L558 121L557 122L556 122L556 136L556 136L556 137L554 137L554 138L553 138L553 139L551 139L550 140L549 142L549 143L548 144L547 146L548 147L549 147L551 150L551 151L551 153L550 154L550 156L550 158L549 158L550 160L550 162L550 163L550 164L549 166L551 167L553 169L554 170L557 170L558 171L558 172L559 173L562 173L564 175L565 178L566 179L568 180L570 182L572 182L575 186L576 187L575 189L575 191L576 191L576 192L576 193L568 194L549 194L534 195L516 195L502 195L491 195L491 168L491 154L490 153L488 152L487 152L486 151L486 149L484 147L485 146L488 144L489 143L489 140L489 138L489 137L489 133L489 131L488 130L487 128L486 126L486 123L486 122L486 120L486 118L485 117L485 112L485 111L485 108L485 106L485 103L483 98L482 95L482 94L482 92L481 92L481 89L481 87L481 85L481 83L481 81L481 79L481 78L481 77L480 74L479 71L480 71Z"},{"name":"Mississippi","code":"MS","d":"M645 493L647 493L646 494L645 493ZM639 493L640 493L643 493L641 493L639 493ZM635 494L637 494L636 494L635 494ZM632 494L633 494L633 494L632 494ZM589 433L590 432L589 431L589 430L590 430L591 430L591 428L591 427L591 427L590 428L590 426L590 425L591 423L590 423L590 424L589 425L588 424L590 423L590 422L589 422L589 423L588 423L589 422L588 420L589 420L589 419L587 418L589 417L590 418L591 418L590 417L590 417L589 417L589 416L592 415L591 415L590 413L591 412L590 411L590 411L591 410L591 411L592 411L592 410L593 409L594 408L592 408L592 407L593 406L594 407L595 406L593 406L592 405L593 405L594 405L594 404L595 404L596 403L595 402L597 402L597 403L597 402L597 401L599 400L599 400L598 399L599 398L598 396L598 395L599 394L599 395L600 395L600 394L600 394L599 394L598 393L599 391L599 393L600 393L601 392L600 391L600 390L601 389L601 391L602 390L604 389L604 388L603 387L619 386L630 385L642 384L643 386L644 386L643 396L643 413L643 444L642 455L643 460L645 478L647 490L644 491L644 490L643 491L643 490L641 490L640 491L638 490L638 490L636 490L633 491L629 492L629 491L629 491L628 491L628 492L627 494L626 495L625 495L624 496L623 495L622 493L622 492L621 491L621 490L620 488L619 487L618 486L618 485L618 484L618 483L618 482L619 478L620 477L619 477L606 478L594 479L583 479L584 478L584 478L583 476L583 475L582 474L582 473L584 473L585 473L585 472L583 470L584 469L585 471L585 471L585 470L585 469L584 467L586 467L586 466L586 465L585 466L584 465L585 464L586 465L587 465L586 463L587 462L589 462L589 461L587 461L587 460L589 459L590 458L590 457L592 456L591 455L591 454L592 455L593 454L593 452L593 452L593 453L591 453L590 452L593 450L594 451L594 450L594 449L595 449L595 448L594 448L593 447L594 446L593 446L593 446L591 445L591 444L593 445L593 445L592 443L593 442L593 441L592 442L591 443L590 441L591 440L592 439L590 439L590 437L590 437L591 436L591 434L590 434L590 435L590 435L589 435L589 433Z"},{"name":"Missouri","code":"MO","d":"M502 262L521 262L531 262L540 261L545 261L553 261L565 260L571 260L572 261L573 261L573 262L574 263L575 263L575 264L575 265L577 265L576 266L575 268L575 271L576 273L577 275L577 276L577 277L578 278L578 280L580 282L581 282L582 284L583 284L584 286L588 288L590 290L591 294L590 294L591 296L591 297L593 299L593 298L595 296L598 297L599 297L601 298L601 300L599 301L600 303L600 304L599 306L598 308L598 309L597 312L598 313L599 314L600 315L601 316L602 316L603 318L603 317L604 319L606 319L605 320L605 320L606 321L607 320L608 321L609 321L611 322L610 323L611 323L612 324L613 324L613 326L614 326L613 327L614 328L615 330L615 331L615 332L614 332L614 333L615 334L615 335L617 338L617 339L619 340L619 339L618 338L619 338L620 339L620 340L621 340L622 340L621 343L621 344L622 345L621 345L620 346L621 347L621 348L620 350L620 350L618 348L617 349L617 351L616 352L616 352L616 351L616 350L614 350L614 350L615 352L615 352L615 353L615 354L615 355L614 355L614 356L615 357L614 358L612 357L612 358L614 359L614 360L613 361L612 363L612 363L600 364L601 362L603 361L603 360L605 358L605 357L605 355L605 354L604 354L604 352L586 353L565 354L550 355L536 355L523 355L523 344L523 326L523 322L522 307L522 295L523 294L521 294L521 294L520 294L519 293L519 293L518 292L517 291L517 290L517 289L517 289L516 288L515 287L515 287L514 286L514 285L514 285L515 283L516 282L516 281L517 281L518 280L517 281L517 280L517 279L516 278L517 278L515 278L515 278L513 278L513 278L512 277L510 275L509 275L508 275L508 274L508 272L507 271L507 270L506 269L506 269L505 268L503 265L503 264L504 263L504 262L503 263L502 263L502 262Z"},{"name":"Montana","code":"MT","d":"M198 39L211 42L223 44L233 46L243 48L265 52L273 53L285 55L303 58L314 59L326 61L340 62L353 64L364 65L377 66L375 88L375 91L374 103L372 124L371 134L370 148L369 156L369 156L358 155L340 153L339 153L334 152L321 151L320 151L298 148L289 147L289 147L279 145L273 144L271 144L263 143L262 143L258 142L257 151L256 154L255 153L255 152L254 151L254 150L253 148L252 147L251 148L250 148L250 149L249 150L250 151L249 151L248 151L247 151L246 150L246 151L244 151L243 150L241 150L241 150L239 150L238 150L237 149L237 149L235 149L235 151L233 150L233 150L232 150L230 149L230 149L229 149L227 150L228 151L227 152L227 151L225 149L225 148L225 147L224 146L225 145L225 144L224 143L224 142L223 140L221 141L221 140L219 139L219 137L220 136L220 134L219 133L220 133L219 132L218 131L217 129L217 127L217 126L217 124L217 123L216 123L217 121L216 120L215 118L214 118L214 119L213 120L212 121L211 121L210 121L209 122L208 123L207 121L207 120L205 120L206 119L205 118L206 118L207 116L206 115L207 113L209 114L209 113L209 112L209 111L208 110L208 109L209 108L208 107L208 106L210 106L210 104L210 104L211 103L211 102L212 101L212 99L212 99L213 96L213 96L212 96L211 96L209 96L209 95L209 94L208 93L207 94L207 92L206 91L205 90L205 88L205 87L204 87L204 85L202 83L202 82L201 80L200 79L198 78L198 77L195 74L197 74L196 74L196 73L196 72L197 71L196 70L197 69L196 67L196 66L195 65L194 62L193 62L198 39Z"},{"name":"Nebraska","code":"NE","d":"M365 201L376 202L391 203L402 204L404 204L422 205L434 205L448 206L457 206L457 207L461 209L463 210L464 212L466 212L467 211L467 210L468 209L469 210L470 210L472 210L474 210L475 210L476 210L478 210L478 211L479 212L482 212L482 213L483 213L485 213L485 214L486 214L487 215L486 216L487 217L488 218L489 218L490 219L491 218L491 218L492 219L491 220L491 222L492 222L493 224L492 225L492 226L494 227L494 229L494 229L494 230L496 230L495 232L496 234L497 234L496 237L496 238L496 238L497 239L496 240L497 241L498 240L498 241L498 242L499 242L499 245L500 245L500 246L499 246L500 248L501 248L500 249L500 250L500 252L501 253L501 254L501 255L501 255L501 258L500 259L501 260L502 260L502 261L502 262L502 263L503 263L504 262L504 263L503 264L503 265L505 268L506 269L506 269L507 270L507 271L508 272L508 274L508 275L509 275L510 275L495 275L480 275L465 275L454 275L443 274L432 274L422 274L405 273L394 272L395 249L382 248L371 247L361 247L365 201Z"},{"name":"Nevada","code":"NV","d":"M101 179L105 180L113 182L122 185L125 185L139 189L149 191L160 193L177 197L182 198L185 198L189 199L198 201L197 204L192 230L191 236L183 280L180 291L176 311L176 313L175 321L174 327L173 332L172 332L170 335L168 335L167 334L168 333L167 332L164 331L164 331L163 331L162 331L160 331L160 331L160 333L159 335L160 337L160 337L159 338L159 339L159 340L159 341L159 342L158 345L159 347L159 349L159 353L158 354L157 354L158 355L157 356L144 338L132 319L117 296L102 273L95 263L83 246L85 241L89 224L92 212L94 204L101 179Z"},{"name":"New Hampshire","code":"NH","d":"M886 111L886 109L887 108L887 106L886 105L887 105L888 103L889 104L891 104L891 102L895 114L898 123L901 135L903 140L903 140L904 144L905 145L908 147L908 149L910 150L910 153L910 155L909 155L908 155L906 156L906 157L905 157L904 158L905 159L903 159L903 161L892 163L884 165L882 163L882 162L882 160L882 160L883 159L882 158L882 156L882 156L882 153L882 152L881 151L881 148L881 147L881 146L881 145L881 143L882 141L882 139L883 136L882 136L883 133L882 131L882 130L882 129L882 128L884 127L885 126L885 126L886 125L887 123L888 122L887 121L888 121L888 119L887 119L886 117L886 116L886 114L887 113L886 111L886 111Z"},{"name":"New Jersey","code":"NJ","d":"M849 246L850 244L850 242L851 241L852 240L853 240L855 239L855 237L858 234L859 233L859 232L860 232L861 231L860 230L858 229L858 228L856 228L855 227L854 226L853 226L853 224L852 223L851 223L850 221L850 219L851 219L852 216L850 214L850 214L851 212L852 211L852 211L853 209L853 206L854 205L855 204L862 207L870 209L870 209L870 212L869 216L869 217L867 218L867 220L867 221L867 222L868 223L869 222L871 222L872 222L872 225L872 229L873 234L873 237L873 239L871 245L870 248L868 249L867 251L866 254L865 257L864 259L863 259L863 259L863 258L863 255L863 254L862 253L860 254L860 253L859 254L859 254L858 253L855 252L854 251L854 252L852 251L852 250L850 250L850 248L850 247L849 246L849 246Z"},{"name":"New Mexico","code":"NM","d":"M265 328L279 330L293 332L303 333L303 333L315 334L335 336L337 337L349 338L373 339L372 351L371 351L370 361L367 408L365 430L363 453L350 452L339 451L328 450L318 449L295 447L295 449L294 449L295 451L296 452L279 450L264 448L263 458L247 456L251 424L253 413L258 379L260 365L265 328Z"},{"name":"New York","code":"NY","d":"M899 196L899 195L901 194L900 196L899 196ZM897 198L898 198L897 199L897 198ZM869 217L870 218L869 220L867 221L867 220L867 218L869 217ZM816 154L816 153L816 154L816 154ZM814 154L815 153L815 153L814 154ZM768 200L769 199L772 196L773 194L775 193L776 192L777 191L778 190L778 189L780 187L780 186L780 185L778 182L777 182L777 180L776 180L776 179L775 176L779 174L782 173L784 172L788 171L790 171L792 170L796 170L798 171L800 172L802 171L806 169L808 170L809 169L810 169L811 168L813 167L814 165L817 162L818 162L819 161L820 160L819 158L818 155L817 154L817 153L818 153L818 154L819 153L819 152L818 151L817 152L817 151L816 150L815 150L815 149L815 146L816 146L817 145L817 144L819 142L821 141L822 140L822 138L825 132L829 128L831 126L832 124L833 124L834 123L836 123L837 123L842 122L845 121L849 120L858 118L858 120L858 122L859 122L859 124L859 127L861 129L861 131L861 132L862 134L862 135L861 136L861 140L862 140L862 142L863 143L863 144L864 145L864 149L864 150L864 150L865 149L866 148L866 149L867 150L868 156L870 166L870 167L871 168L871 183L871 184L873 194L874 201L876 203L872 206L874 208L874 208L874 209L873 210L873 212L872 212L873 213L874 211L875 211L875 210L877 209L877 209L879 209L878 208L879 208L882 208L883 208L883 207L884 206L889 205L891 204L893 202L895 200L897 199L896 201L897 201L898 201L898 201L900 202L902 200L902 199L903 199L903 200L900 202L896 206L893 208L890 210L886 213L883 214L882 215L881 215L878 217L874 218L872 219L872 219L870 219L870 218L870 217L869 216L870 212L870 209L870 209L862 207L855 204L854 203L854 203L853 203L853 203L851 203L851 202L850 202L849 201L848 200L848 200L847 197L846 196L846 195L845 194L843 195L843 193L841 192L835 194L825 196L815 198L812 198L800 201L788 203L783 204L769 206L768 200Z"},{"name":"North Carolina","code":"NC","d":"M864 329L866 329L867 331L867 331L866 331L866 331L864 329ZM863 350L864 348L871 343L871 338L870 336L869 332L869 333L871 336L871 338L871 344L870 345L867 346L863 350ZM756 334L761 334L765 333L771 332L773 332L780 331L794 329L812 326L840 320L842 319L859 316L861 320L862 323L864 325L865 327L868 332L868 332L866 328L865 328L864 328L863 324L861 322L860 321L861 321L860 319L859 318L857 318L857 317L856 318L857 319L858 319L858 319L859 320L862 326L862 326L861 325L861 325L859 323L859 323L859 324L860 325L858 325L855 323L854 323L856 324L857 325L857 326L855 327L853 326L855 327L855 327L850 326L852 328L853 328L853 328L851 329L851 330L850 331L849 331L848 330L847 330L846 328L845 327L846 325L846 325L845 327L845 328L846 330L847 332L847 332L847 333L850 332L852 331L853 331L856 330L858 329L859 330L859 331L859 332L859 333L860 337L860 336L860 334L860 331L861 330L860 330L862 329L863 329L865 332L865 333L866 335L865 335L866 336L865 337L863 338L862 341L862 342L861 342L861 343L860 344L858 344L856 345L853 345L852 344L851 342L853 342L853 341L851 341L851 341L850 342L851 343L852 345L849 344L848 345L847 344L845 344L843 343L843 344L844 345L846 345L850 346L853 346L854 347L853 350L853 351L853 352L850 355L846 354L846 355L848 356L851 356L853 354L855 353L855 351L856 352L856 353L857 353L858 353L857 352L859 352L859 354L858 355L857 356L856 359L856 360L855 360L855 359L854 358L854 360L853 360L856 361L858 358L859 356L860 354L862 350L863 350L862 352L858 357L857 361L856 362L856 361L853 361L851 361L847 363L844 365L842 367L840 368L838 371L836 374L835 377L833 383L833 384L833 384L832 384L829 384L825 385L823 386L807 375L805 373L798 368L795 369L778 372L778 369L774 365L772 367L772 365L760 366L753 367L749 367L748 368L747 367L747 368L746 368L742 371L741 371L741 371L735 374L726 375L726 375L713 377L713 371L714 370L714 371L716 370L717 369L717 368L717 367L717 367L717 366L719 365L719 364L720 364L721 363L726 362L727 361L728 360L729 359L729 359L730 358L731 357L732 357L733 356L735 356L735 355L736 353L736 352L737 352L737 352L738 351L738 350L740 348L741 349L741 351L742 351L743 349L744 347L746 346L747 346L747 345L749 345L749 346L751 346L752 341L754 340L756 340L755 338L756 337L755 336L756 334Z"},{"name":"North Dakota","code":"ND","d":"M377 66L387 67L404 68L421 69L431 69L445 70L455 70L469 71L480 71L479 71L480 74L481 77L481 78L481 79L481 81L481 83L481 85L481 87L481 89L481 92L482 92L482 94L482 95L483 98L485 103L485 106L485 108L485 111L485 112L485 117L486 118L486 120L486 122L486 123L486 126L487 128L488 130L489 131L489 133L489 137L489 138L489 140L477 140L460 139L443 139L432 138L413 137L404 137L392 136L379 135L371 134L372 124L374 103L375 91L375 88L377 66Z"},{"name":"Ohio","code":"OH","d":"M721 223L722 223L721 223L721 223ZM719 222L719 221L720 221L719 222ZM686 225L693 224L708 222L709 222L710 223L711 222L712 223L715 224L716 225L718 226L719 225L719 224L720 225L721 224L721 225L723 227L726 227L727 226L729 226L730 225L733 223L734 224L738 223L740 221L742 218L744 216L745 216L749 213L750 212L756 209L761 239L759 240L759 241L760 242L761 243L760 245L761 247L761 248L760 248L760 251L760 252L760 253L760 255L760 256L759 257L759 257L759 258L759 259L759 260L759 261L759 262L759 263L758 263L756 266L755 268L754 268L754 269L753 270L752 271L751 271L750 269L748 271L748 273L746 273L746 274L745 276L746 277L745 278L746 279L746 280L746 281L745 281L744 283L743 282L744 281L743 280L742 280L741 280L740 281L740 283L740 284L739 285L740 287L740 289L740 290L738 290L738 291L738 294L737 294L734 295L733 295L733 294L731 292L729 292L728 292L727 288L726 289L725 289L724 290L723 290L723 292L721 292L721 292L719 291L717 291L716 291L715 291L714 293L712 292L712 291L711 291L710 290L708 290L707 290L704 290L703 288L702 287L702 286L701 285L699 285L699 284L699 283L698 284L696 285L695 284L694 283L693 284L691 268L690 257L689 247L686 225Z"},{"name":"Oklahoma","code":"OK","d":"M373 339L378 340L390 341L402 341L414 342L429 343L433 343L443 343L459 344L472 344L489 344L507 344L523 344L523 355L524 360L527 381L527 394L526 421L526 421L525 420L523 420L523 420L522 420L521 418L520 419L518 417L517 416L516 416L514 414L512 414L511 415L509 416L507 415L506 414L503 415L503 415L503 416L502 416L501 416L499 415L499 416L498 416L498 416L496 416L495 417L495 418L494 418L493 418L492 419L492 420L490 418L489 418L488 417L487 417L486 416L487 415L485 415L485 416L484 417L483 416L482 416L482 416L481 414L479 414L480 415L478 417L478 419L476 419L476 417L476 416L476 415L475 414L475 416L473 415L472 417L471 417L471 416L471 415L470 414L469 415L468 414L468 414L468 413L467 413L466 414L464 416L463 416L461 415L462 413L461 412L460 412L459 412L459 410L459 409L459 409L458 410L455 409L454 409L453 410L452 411L450 409L449 408L448 409L447 409L446 408L444 408L443 407L441 407L440 407L439 407L438 404L435 401L435 403L434 403L433 402L432 402L431 403L429 403L428 402L426 399L425 398L424 398L426 354L416 354L406 353L397 352L385 352L372 351L373 339Z"},{"name":"Oregon","code":"OR","d":"M71 70L72 70L73 71L72 72L73 74L74 74L76 74L77 74L80 77L80 78L80 80L80 82L80 83L80 84L79 87L80 88L83 90L84 90L85 91L86 92L87 91L89 91L92 91L93 90L94 90L95 91L98 91L100 92L101 92L103 93L103 95L104 95L105 94L107 95L108 95L108 95L111 94L112 94L113 94L114 95L115 96L118 96L119 95L121 95L123 95L125 95L128 95L129 94L129 94L131 95L134 95L136 96L138 95L139 95L152 98L171 102L171 104L172 107L173 107L173 108L174 108L175 109L175 111L176 112L174 114L174 115L173 116L171 118L169 122L168 124L167 124L166 125L167 126L164 129L162 130L160 133L160 134L158 135L158 136L157 137L158 138L157 139L158 140L159 140L160 141L161 141L161 142L161 142L162 143L161 144L160 145L160 146L159 148L159 149L158 149L158 151L149 191L139 189L125 185L122 185L113 182L105 180L101 179L96 178L84 175L68 170L62 168L58 167L51 165L50 165L47 164L40 162L33 160L32 158L31 157L31 155L31 154L31 152L32 149L34 147L34 145L33 144L33 143L33 142L33 140L35 137L37 135L38 133L38 131L38 131L40 130L41 128L43 126L45 121L48 114L50 110L51 107L51 106L53 103L54 99L55 98L57 93L58 90L59 88L59 86L59 85L60 85L61 81L62 80L61 79L62 78L62 76L62 75L63 75L64 74L64 72L64 69L64 69L65 71L66 71L66 70L68 70L68 71L69 71L70 71L71 70Z"},{"name":"Pennsylvania","code":"PA","d":"M756 209L759 207L762 205L762 204L763 204L765 202L768 200L769 206L783 204L788 203L800 201L812 198L815 198L825 196L835 194L841 192L843 193L843 195L845 194L846 195L846 196L847 197L848 200L848 200L849 201L850 202L851 202L851 203L853 203L853 203L854 203L854 203L855 204L854 205L853 206L853 209L852 211L852 211L851 212L850 214L850 214L852 216L851 219L850 219L850 221L851 223L852 223L853 224L853 226L854 226L855 227L856 228L858 228L858 229L860 230L861 231L860 232L859 232L859 233L858 234L855 237L855 239L853 240L852 240L851 241L849 241L848 241L847 242L846 243L845 245L837 246L825 249L816 251L798 254L782 257L776 258L765 260L761 239L756 209Z"},{"name":"Rhode Island","code":"RI","d":"M911 183L912 183L913 186L912 188L911 186L911 184L910 184L911 187L910 187L910 188L909 188L910 185L910 184L911 183ZM908 188L908 186L909 186L909 188L908 188ZM907 196L907 194L908 196L907 196ZM902 194L902 193L902 191L902 191L901 186L899 178L906 176L906 179L907 179L908 181L909 181L910 182L910 183L909 184L909 183L907 181L907 183L906 184L907 184L908 186L907 186L908 187L908 188L908 191L905 192L904 193L902 194L902 194Z"},{"name":"South Carolina","code":"SC","d":"M735 374L741 371L741 371L742 371L746 368L747 368L747 367L748 368L749 367L753 367L760 366L772 365L772 367L774 365L778 369L778 372L795 369L798 368L805 373L807 375L823 386L820 388L818 390L816 392L815 395L814 396L814 399L813 402L814 404L813 405L811 406L811 408L810 408L809 408L808 408L807 409L807 410L807 410L807 411L805 413L805 414L803 415L802 415L801 415L803 416L803 417L801 418L801 419L799 419L798 420L798 421L797 421L795 423L794 423L793 423L793 423L792 423L792 424L793 425L793 427L791 428L790 429L790 428L788 428L787 428L788 429L790 430L789 431L787 433L786 433L786 434L786 435L785 435L784 433L783 434L781 433L781 432L780 431L781 430L779 427L779 426L777 424L775 424L774 423L774 422L774 421L774 420L773 419L773 418L772 417L772 417L771 416L771 414L770 414L769 413L767 413L766 412L765 411L765 411L764 410L764 409L762 409L762 408L762 406L760 405L759 403L758 403L757 403L756 402L755 400L753 398L751 398L749 396L748 396L747 394L746 394L746 392L744 391L744 389L743 388L742 387L741 385L740 385L738 385L737 385L735 383L734 383L732 382L731 380L732 379L733 378L733 377L735 375L735 374Z"},{"name":"South Dakota","code":"SD","d":"M369 156L369 156L370 148L371 134L379 135L392 136L404 137L413 137L432 138L443 139L460 139L477 140L489 140L489 143L488 144L485 146L484 147L486 149L486 151L487 152L488 152L490 153L491 154L491 168L491 195L488 195L488 197L490 198L489 200L489 200L489 202L490 202L490 203L491 204L490 205L490 206L490 208L489 208L489 210L488 212L488 213L489 215L490 217L490 218L491 218L490 219L489 218L488 218L487 217L486 216L487 215L486 214L485 214L485 213L483 213L482 213L482 212L479 212L478 211L478 210L476 210L475 210L474 210L472 210L470 210L469 210L468 209L467 210L467 211L466 212L464 212L463 210L461 209L457 207L457 206L448 206L434 205L422 205L404 204L402 204L391 203L376 202L365 201L366 188L368 175L369 156Z"},{"name":"Tennessee","code":"TN","d":"M612 363L612 363L613 361L614 360L614 359L612 358L612 357L614 358L615 357L614 356L614 355L615 355L615 354L615 353L615 352L615 352L616 352L616 352L617 351L619 351L627 351L641 350L642 349L641 345L645 345L645 346L652 345L658 345L667 344L668 344L669 343L672 343L678 343L681 343L687 342L693 342L696 341L699 341L704 341L714 340L720 339L720 339L727 338L727 338L737 337L751 335L751 334L756 333L756 334L755 336L756 337L755 338L756 340L754 340L752 341L751 346L749 346L749 345L747 345L747 346L746 346L744 347L743 349L742 351L741 351L741 349L740 348L738 350L738 351L737 352L737 352L736 352L736 353L735 355L735 356L733 356L732 357L731 357L730 358L729 359L729 359L728 360L727 361L726 362L721 363L720 364L719 364L719 365L717 366L717 367L717 367L717 368L717 369L716 370L714 371L714 370L713 371L713 377L701 378L693 379L689 380L685 380L667 382L656 383L642 384L642 384L630 385L619 386L603 387L603 386L605 386L605 384L606 383L607 384L607 383L606 382L607 382L606 381L605 381L605 380L606 380L607 378L606 377L606 378L605 378L605 377L606 376L607 376L607 377L608 376L607 375L607 374L608 374L609 375L609 374L608 373L610 372L610 371L609 371L608 371L608 369L609 369L610 369L611 369L611 368L613 367L612 366L612 366L612 365L613 366L614 365L612 364L612 363Z"},{"name":"Texas","code":"TX","d":"M372 351L385 352L397 352L406 353L416 354L426 354L424 398L425 398L426 399L428 402L429 403L431 403L432 402L433 402L434 403L435 403L435 401L438 404L439 407L440 407L441 407L443 407L444 408L446 408L447 409L448 409L449 408L450 409L452 411L453 410L454 409L455 409L458 410L459 409L459 409L459 410L459 412L460 412L461 412L462 413L461 415L463 416L464 416L466 414L467 413L468 413L468 414L468 414L469 415L470 414L471 415L471 416L471 417L472 417L473 415L475 416L475 414L476 415L476 416L476 417L476 419L478 419L478 417L480 415L479 414L481 414L482 416L482 416L483 416L484 417L485 416L485 415L487 415L486 416L487 417L488 417L489 418L490 418L492 420L492 419L493 418L494 418L495 418L495 417L496 416L498 416L498 416L499 416L499 415L501 416L502 416L503 416L503 415L503 415L506 414L507 415L509 416L511 415L512 414L514 414L516 416L517 416L518 417L520 419L521 418L522 420L523 420L523 420L525 420L526 421L526 421L527 421L527 422L529 422L529 423L530 422L531 423L532 422L533 423L534 422L535 423L535 435L536 458L536 458L537 460L538 460L539 462L540 463L540 464L540 465L540 467L540 468L542 469L541 470L542 471L543 472L543 474L544 474L544 477L545 476L546 479L546 480L545 481L546 482L545 483L546 485L544 486L544 488L543 489L543 490L542 490L542 491L543 492L543 493L542 495L542 496L543 497L543 499L543 500L543 501L543 502L542 504L540 506L539 508L541 510L541 511L537 511L535 512L525 516L524 517L523 518L522 518L522 517L524 516L526 515L526 514L528 514L527 513L523 514L522 514L524 512L524 511L524 509L523 509L521 509L520 511L519 512L518 511L518 512L518 513L517 514L518 515L520 515L519 516L520 517L520 519L519 520L516 523L515 522L515 524L515 524L518 522L520 520L521 519L522 518L523 519L522 520L517 523L515 525L513 527L510 530L509 530L502 534L497 537L494 538L492 539L490 541L489 542L485 544L482 547L480 548L478 551L477 553L475 555L474 558L472 562L471 566L470 570L470 574L471 576L472 580L472 583L474 587L474 592L474 591L473 589L472 582L470 582L471 581L470 576L470 574L470 569L470 564L472 560L473 557L474 556L475 554L475 554L475 553L476 551L478 550L480 546L482 546L482 545L483 545L486 543L489 541L489 540L486 541L486 542L484 542L484 540L483 540L482 543L482 543L482 544L479 546L477 546L477 545L475 546L474 547L475 548L477 547L477 548L477 549L474 553L473 553L473 552L471 552L471 552L469 552L468 552L468 552L468 553L470 553L470 554L470 555L473 556L471 559L470 561L470 562L469 564L467 565L467 565L468 565L468 563L467 563L465 565L465 566L467 566L469 566L469 566L469 572L468 576L468 576L469 580L469 582L469 583L470 583L472 588L471 589L472 592L472 592L473 592L473 593L474 593L474 595L472 595L472 596L470 596L470 598L468 597L468 597L467 597L466 596L465 595L464 594L462 593L461 593L459 592L458 593L456 592L456 593L455 592L454 593L452 592L450 591L450 590L449 590L447 589L447 589L445 588L443 588L442 586L441 586L440 585L439 585L437 584L436 584L435 584L434 584L435 583L434 581L433 581L433 579L433 578L433 577L432 576L431 574L430 573L430 573L429 572L429 571L429 570L428 570L428 568L428 567L429 567L428 565L427 564L427 564L426 563L427 563L428 560L427 559L427 558L426 557L425 556L425 556L424 555L423 556L422 554L420 552L420 551L420 550L419 549L419 548L418 548L417 546L417 546L416 544L414 543L412 541L412 540L411 538L411 537L411 536L411 536L410 536L410 535L410 534L409 533L409 533L408 532L408 530L408 530L408 528L407 528L407 527L406 527L406 525L405 522L403 520L404 520L403 519L402 518L401 517L399 516L398 514L396 513L396 512L394 512L395 511L394 509L394 511L393 510L393 509L392 509L392 506L390 506L389 506L389 505L388 506L386 506L384 505L383 505L381 505L381 504L379 505L378 505L377 503L376 503L374 503L373 503L372 505L370 504L369 505L369 505L366 506L366 507L365 507L365 508L365 508L365 509L364 510L363 514L362 514L361 516L362 516L361 517L359 518L359 519L357 520L357 522L355 521L354 521L353 520L351 520L350 519L349 518L348 517L347 517L345 516L344 514L343 514L340 513L337 511L335 508L334 508L333 506L331 505L330 504L330 501L329 500L328 498L328 497L328 495L328 494L328 492L327 489L325 487L325 486L325 483L324 482L324 482L323 480L322 480L321 478L320 478L319 477L318 477L316 475L316 475L313 472L312 470L312 469L309 468L308 465L307 464L306 463L305 462L304 462L302 460L301 458L301 458L300 456L299 454L298 453L297 453L296 452L295 451L294 449L295 449L295 447L318 449L328 450L339 451L350 452L363 453L365 430L367 408L370 361L371 351L372 351Z"},{"name":"Utah","code":"UT","d":"M198 201L200 202L228 206L229 207L240 208L247 210L244 232L253 234L269 236L277 237L276 246L276 249L271 280L270 289L268 299L268 302L266 316L266 319L265 328L254 327L239 324L239 324L223 322L203 318L185 315L176 313L176 311L180 291L183 280L191 236L192 230L197 204L198 201Z"},{"name":"Vermont","code":"VT","d":"M858 118L865 116L874 114L886 111L886 111L887 113L886 114L886 116L886 117L887 119L888 119L888 121L887 121L888 122L887 123L886 125L885 126L885 126L884 127L882 128L882 129L882 130L882 131L883 133L882 136L883 136L882 139L882 141L881 143L881 145L881 146L881 147L881 148L881 151L882 152L882 153L882 156L882 156L882 158L883 159L882 160L882 160L882 162L882 163L884 165L871 168L870 167L870 166L868 156L867 150L866 149L866 148L865 149L864 150L864 150L864 149L864 145L863 144L863 143L862 142L862 140L861 140L861 136L862 135L862 134L861 132L861 131L861 129L859 127L859 124L859 122L858 122L858 120L858 118Z"},{"name":"Virginia","code":"VA","d":"M856 284L856 283L863 281L861 284L861 285L860 285L859 286L858 288L858 291L859 292L858 295L858 295L857 298L856 299L857 300L857 301L856 303L855 304L854 304L854 303L853 301L853 299L853 298L853 297L853 292L854 289L854 288L855 287L856 286L855 285L854 285L856 284ZM850 285L849 286L849 285L850 285ZM747 313L748 314L747 315L748 316L748 317L750 319L751 319L753 320L755 320L757 318L758 318L759 316L761 318L763 317L767 315L768 315L767 314L767 313L769 314L770 313L772 311L773 311L774 312L777 309L777 309L776 309L776 308L778 306L777 306L776 305L777 304L776 304L777 302L779 299L780 298L781 295L780 294L782 291L783 291L782 290L783 289L784 287L783 286L784 283L786 283L787 285L790 286L792 284L792 283L793 280L793 280L793 279L793 277L794 275L797 277L797 274L798 272L799 273L800 271L800 271L801 270L801 270L803 266L802 265L803 264L802 264L803 262L803 259L813 265L814 261L814 260L816 260L817 260L817 261L819 262L818 263L818 264L819 265L822 265L823 266L823 266L825 266L826 267L827 268L828 269L828 270L828 272L827 273L827 274L826 274L825 274L825 275L825 278L825 279L826 281L828 280L829 280L830 279L830 279L830 281L832 282L832 283L834 283L836 283L836 284L837 283L838 283L839 284L840 284L841 285L841 285L843 286L844 286L846 287L846 289L845 289L846 292L845 293L846 293L845 294L843 293L842 294L841 291L838 291L836 289L835 289L835 288L834 287L834 288L836 289L838 291L839 292L840 292L841 294L842 295L844 294L844 295L846 295L846 296L846 297L847 296L848 297L848 299L848 300L847 299L845 299L845 300L846 301L847 302L845 303L845 303L846 303L847 304L847 304L849 305L849 306L849 308L848 307L847 309L846 308L844 307L843 306L843 305L842 304L841 304L840 305L839 304L838 304L838 304L837 305L837 305L839 305L841 306L841 306L841 304L842 306L842 307L843 308L844 308L846 309L846 311L848 310L849 309L849 310L849 309L850 308L850 309L853 309L854 308L855 308L856 312L858 314L859 316L842 319L840 320L812 326L794 329L780 331L773 332L771 332L765 333L761 334L756 334L756 333L751 334L751 335L737 337L727 338L727 338L720 339L720 338L722 337L724 337L726 335L728 334L729 334L729 333L730 332L732 331L733 330L733 329L733 328L734 327L735 326L735 325L738 322L740 321L741 320L747 313Z"},{"name":"Washington","code":"WA","d":"M93 51L94 49L95 48L95 49L95 50L96 51L94 52L93 51L93 51ZM99 25L99 24L100 25L99 25L99 25ZM99 20L99 20L100 23L99 22L99 20ZM98 24L98 23L99 24L98 25L98 24ZM95 31L97 29L98 28L99 28L99 29L99 31L99 31L98 30L96 32L97 32L97 33L98 34L97 35L97 37L98 35L99 36L100 37L100 39L99 40L98 39L98 38L97 38L97 37L96 36L97 34L97 33L96 33L95 31ZM96 18L98 20L96 19L96 18ZM94 20L95 19L95 20L94 20ZM95 13L96 14L95 14L95 13ZM91 21L92 21L93 21L93 23L94 23L93 21L96 20L97 20L98 22L97 22L97 24L97 25L96 25L96 27L95 27L94 25L93 25L92 24L91 21ZM91 19L92 19L93 21L91 19ZM184 36L181 50L178 59L175 76L171 92L170 94L171 96L171 99L170 100L171 102L152 98L139 95L138 95L136 96L134 95L131 95L129 94L129 94L128 95L125 95L123 95L121 95L119 95L118 96L115 96L114 95L113 94L112 94L111 94L108 95L108 95L107 95L105 94L104 95L103 95L103 93L101 92L100 92L98 91L95 91L94 90L93 90L92 91L89 91L87 91L86 92L85 91L84 90L83 90L80 88L79 87L80 84L80 83L80 82L80 80L80 78L80 77L77 74L76 74L74 74L73 74L72 72L73 71L72 70L71 70L69 70L69 69L68 69L67 69L66 69L65 69L65 67L64 67L63 68L64 65L65 63L66 60L66 61L65 63L65 66L66 66L66 64L66 63L66 64L68 63L67 60L68 60L70 59L69 58L69 58L67 59L66 57L66 56L66 54L67 54L67 55L69 54L71 54L70 53L69 52L69 51L68 51L67 51L67 53L66 53L67 50L67 48L67 45L67 43L67 39L68 34L67 33L67 32L66 31L66 30L66 28L66 25L66 24L67 24L68 21L67 19L69 19L70 20L72 23L73 24L74 24L76 26L75 26L77 27L79 28L81 28L82 30L85 30L85 31L87 31L89 31L90 32L90 33L92 33L92 34L92 35L92 36L93 36L93 35L93 34L93 33L95 33L95 34L94 34L94 35L95 34L95 36L94 36L95 37L95 39L95 39L94 39L94 40L93 40L92 42L91 43L91 42L92 40L91 40L90 42L88 43L85 46L84 48L86 49L88 49L89 48L85 48L85 48L88 44L90 43L92 43L92 42L94 41L95 40L96 40L96 38L97 39L96 43L95 42L96 43L95 45L95 46L94 47L94 47L95 48L94 48L93 50L93 50L92 52L92 52L91 54L90 52L91 50L90 51L89 52L90 53L91 54L90 54L89 55L88 54L88 52L89 51L89 50L88 51L88 53L87 54L88 54L88 55L89 56L91 55L91 55L93 52L93 52L94 53L95 52L96 52L96 49L96 47L98 46L97 45L98 44L98 42L99 41L100 39L102 39L102 38L101 37L100 35L101 34L101 33L100 32L99 33L99 35L100 36L99 34L98 34L98 33L99 32L100 31L101 32L101 31L101 30L100 29L99 28L100 27L98 28L98 26L98 25L99 25L100 26L100 27L101 27L101 25L102 25L102 24L102 23L102 22L102 21L102 20L101 20L100 21L100 20L99 19L100 18L99 17L100 16L99 16L100 14L110 17L115 19L122 21L130 23L140 25L153 29L167 32L184 36Z"},{"name":"West Virginia","code":"WV","d":"M733 295L734 295L737 294L738 294L738 291L738 290L740 290L740 289L740 287L739 285L740 284L740 283L740 281L741 280L742 280L743 280L744 281L743 282L744 283L745 281L746 281L746 280L746 279L745 278L746 277L745 276L746 274L746 273L748 273L748 271L750 269L751 271L752 271L753 270L754 269L754 268L755 268L756 266L758 263L759 263L759 262L759 261L759 260L759 259L759 258L759 257L759 257L760 256L760 255L760 253L760 252L760 251L760 248L761 248L761 247L760 245L761 243L760 242L759 241L759 240L761 239L765 260L776 258L782 257L784 269L785 268L786 267L787 266L787 266L788 264L789 263L790 261L791 261L792 262L794 257L794 257L795 257L795 257L797 259L799 259L800 258L801 257L800 257L801 257L802 255L803 255L805 253L806 254L808 255L809 254L811 254L811 255L810 255L811 256L812 257L813 258L813 260L814 260L814 261L813 265L803 259L803 262L802 264L803 264L802 265L803 266L801 270L801 270L800 271L800 271L799 273L798 272L797 274L797 277L794 275L793 277L793 279L793 280L793 280L792 283L792 284L790 286L787 285L786 283L784 283L783 286L784 287L783 289L782 290L783 291L782 291L780 294L781 295L780 298L779 299L777 302L776 304L777 304L776 305L777 306L778 306L776 308L776 309L777 309L777 309L774 312L773 311L772 311L770 313L769 314L767 313L767 314L768 315L767 315L763 317L761 318L759 316L758 318L757 318L755 320L753 320L751 319L750 319L748 317L748 316L747 315L748 314L747 313L744 313L744 312L743 312L742 311L741 311L740 309L738 307L738 307L737 306L736 306L737 304L736 304L735 303L734 302L733 301L734 301L734 299L734 298L734 297L733 297L733 295Z"},{"name":"Wisconsin","code":"WI","d":"M642 146L642 145L644 145L643 147L642 146ZM636 150L636 150L637 151L636 150ZM584 113L584 112L585 112L585 113L584 113ZM583 116L584 115L584 116L583 116ZM581 115L583 114L583 115L582 116L581 115ZM580 117L581 116L581 116L580 117ZM580 114L581 115L580 116L580 114ZM579 113L581 112L582 113L583 113L583 113L582 114L581 115L579 113ZM579 119L580 118L582 116L583 117L581 118L581 118L580 118L579 119L579 119ZM576 114L577 114L577 115L576 114ZM560 121L561 122L563 121L567 120L568 120L573 117L573 117L575 116L576 115L577 115L578 115L580 116L579 118L578 119L578 120L577 121L577 123L578 123L580 122L580 121L583 122L585 123L586 124L587 123L587 124L588 124L589 126L590 128L607 131L611 133L612 134L613 133L615 134L615 134L616 133L618 134L618 134L620 135L620 134L623 135L623 136L622 137L623 138L625 138L625 139L626 138L628 139L628 140L628 141L628 142L628 143L628 143L628 144L628 145L627 146L627 147L628 148L629 148L630 147L631 148L630 149L630 152L631 153L632 153L632 154L632 156L629 157L629 158L628 160L628 161L627 162L627 164L627 165L626 166L627 166L628 166L628 165L630 164L631 163L631 162L633 159L635 157L636 158L636 157L636 155L638 153L638 151L639 151L640 150L640 149L641 148L642 148L642 149L641 149L641 152L640 153L640 154L639 155L639 156L639 157L639 157L638 158L638 159L637 162L636 165L635 171L636 173L635 174L634 176L633 181L633 182L633 183L633 185L632 188L632 190L631 192L631 195L632 197L631 198L632 199L632 200L632 201L633 202L633 203L633 205L634 206L634 207L634 210L634 212L629 213L618 214L610 214L596 215L587 215L587 214L586 212L582 212L580 211L579 207L578 207L578 206L578 204L577 202L579 199L578 197L577 196L577 195L576 194L576 193L576 192L576 191L575 191L575 189L576 187L575 186L572 182L570 182L568 180L566 179L565 178L564 175L562 173L559 173L558 172L558 171L557 170L554 170L553 169L551 167L549 166L550 164L550 163L550 162L550 160L549 158L550 158L550 156L550 154L551 153L551 151L551 150L549 147L548 147L547 146L548 144L549 143L549 142L550 140L551 139L553 139L553 138L554 138L554 137L556 137L556 136L556 136L556 122L557 122L558 121L559 120L560 121Z"},{"name":"Wyoming","code":"WY","d":"M369 156L368 175L366 188L365 201L361 247L351 246L348 245L333 244L325 243L321 243L301 240L286 238L277 237L269 236L253 234L244 232L247 210L249 198L250 193L256 154L257 151L258 142L262 143L263 143L271 144L273 144L279 145L289 147L289 147L298 148L320 151L321 151L334 152L339 153L340 153L358 155L369 156Z"}]}; diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/lib/color.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/lib/color.js new file mode 100644 index 00000000..9a60f663 --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/lib/color.js @@ -0,0 +1,115 @@ +// ============================================================================ +// Diverging color scale + nice-axis math. +// +// Custom properties (--div-neg/mid/pos) are declared on .viz-root and swap for +// dark mode there, so the ramp must be read from that element. makeRatioColor() +// resolves the three endpoints ONCE and returns a closure — the map calls it a +// single time per render, then colors all ~50 states without re-reading styles. +// ============================================================================ +(function (SV) { + 'use strict'; + /** + * Parses a CSS color as returned by getComputedStyle. + * @param {string} str `#rgb`, `#rrggbb`, `rgb()`, or `rgba()`. + * @returns {number[]} `[r, g, b]`; mid gray for anything unrecognized. + */ + function parseColor(str) { + str = String(str).trim(); + if (str[0] === '#') { + let h = str.slice(1); + if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; + return [ + parseInt(h.slice(0, 2), 16), + parseInt(h.slice(2, 4), 16), + parseInt(h.slice(4, 6), 16), + ]; + } + const m = str.match(/rgba?\(([^)]+)\)/); + if (m) { + const p = m[1].split(',').map((x) => parseFloat(x)); + return [p[0], p[1], p[2]]; + } + return [128, 128, 128]; + } + + /** + * Linear interpolation from `a` to `b`. + * @param {number} a + * @param {number} b + * @param {number} t 0..1 + * @returns {number} + */ + const lerp = (a, b, t) => a + (b - a) * t; + /** + * Mixes two `[r, g, b]` colors. + * @param {number[]} c1 + * @param {number[]} c2 + * @param {number} t 0 (all c1) .. 1 (all c2) + * @returns {string} `rgb(r,g,b)` + */ + const mix = (c1, c2, t) => + `rgb(${Math.round(lerp(c1[0], c2[0], t))},${Math.round(lerp(c1[1], c2[1], t))},${Math.round(lerp(c1[2], c2[2], t))})`; + + /** + * Reads a CSS custom property's computed value. + * @param {Element | null} el The element the property is declared on (defaults to ). + * @param {string} name e.g. `--div-pos` + * @returns {string} The trimmed value, or "" if unset. + */ + function cssVar(el, name) { + const host = el || document.documentElement; + return getComputedStyle(host).getPropertyValue(name).trim(); + } + + /** + * Diverging profit-ratio scale: blue (+) ↔ gray (0) ↔ red (−), clamped to ±50%. + * Reads the endpoints once, so call it once per render. + * @param {Element} vizRootEl The .viz-root element that declares --div-neg/mid/pos. + * @returns {(ratio: number) => string} Maps a ratio to `rgb()`. + */ + function makeRatioColor(vizRootEl) { + const neg = parseColor(cssVar(vizRootEl, '--div-neg')); + const mid = parseColor(cssVar(vizRootEl, '--div-mid')); + const pos = parseColor(cssVar(vizRootEl, '--div-pos')); + return function ratioColor(ratio) { + const t = Math.max(-1, Math.min(1, ratio / 0.5)); // -1..1 + return t >= 0 ? mix(mid, pos, t) : mix(mid, neg, -t); + }; + } + + /** + * Sequential Sales ramp for the Product highlight table: light tint (low) → + * series blue (high), endpoints read from --heat-sales-lo/-hi so dark mode swaps + * in one place. + * @param {Element} vizRootEl The .viz-root element that declares the endpoints. + * @param {number} min + * @param {number} max + * @returns {{ color: (v: number) => string, t: (v: number) => number }} `color` + * maps a value to `rgb()`; `t` maps it to 0..1 so the caller can pick a + * readable text color (white once the cell is dark enough). + */ + function makeSalesColor(vizRootEl, min, max) { + const lo = parseColor(cssVar(vizRootEl, '--heat-sales-lo')); + const hi = parseColor(cssVar(vizRootEl, '--heat-sales-hi')); + const span = max > min ? max - min : 1; + const t = (v) => Math.max(0, Math.min(1, (v - min) / span)); + return { color: (v) => mix(lo, hi, t(v)), t }; + } + + /** + * A "nice" axis tick step (1, 2, or 5 × 10^n) for a 0..max domain. + * @param {number} max + * @param {number} [targetTicks=3] Approximate number of ticks. + * @returns {number} + */ + function niceStep(max, targetTicks) { + const raw = max / (targetTicks || 3); + if (raw <= 0) return 1; + const p = Math.pow(10, Math.floor(Math.log10(raw))); + const n = raw / p; + const step = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10; + return step * p; + } + + Object.assign(SV, { parseColor, lerp, mix, cssVar, makeRatioColor, makeSalesColor, niceStep }); +})((window.SV = window.SV || {})); diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/lib/constants.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/lib/constants.js new file mode 100644 index 00000000..f061d8d4 --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/lib/constants.js @@ -0,0 +1,43 @@ +// Fixed facet + filter orders. Categorical identity is assigned in a stable +// order, never cycled, so a series keeps the same color in every view. +(function (SV) { + 'use strict'; + const SEGMENTS = ['Consumer', 'Corporate', 'Home Office']; + const CATEGORIES = ['Furniture', 'Office Supplies', 'Technology']; + const REGIONS = ['Central', 'East', 'South', 'West']; + + // Sub-Categories in the canonical Superstore order, grouped under their Category. + // Drives the row order of the Product tab's per-product strip plot. + const SUB_CATEGORIES = { + Furniture: ['Bookcases', 'Chairs', 'Furnishings', 'Tables'], + 'Office Supplies': [ + 'Appliances', + 'Art', + 'Binders', + 'Envelopes', + 'Fasteners', + 'Labels', + 'Paper', + 'Storage', + 'Supplies', + ], + Technology: ['Accessories', 'Copiers', 'Machines', 'Phones'], + }; + + const MONTH_NAMES = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + Object.assign(SV, { SEGMENTS, CATEGORIES, REGIONS, SUB_CATEGORIES, MONTH_NAMES }); +})((window.SV = window.SV || {})); diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/lib/dom.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/lib/dom.js new file mode 100644 index 00000000..58630123 --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/lib/dom.js @@ -0,0 +1,116 @@ +// ============================================================================ +// Tiny dependency-free DOM builder: the JSX replacement for this no-build app. +// +// h(tag, props, ...children) → HTML element +// svg(tag, props, ...children) → SVG-namespaced element (svg/g/path/line/text/…) +// frag(...children) → a DocumentFragment +// clear(el) → remove all children +// observeWidth(el, cb) → ResizeObserver wired to el.clientWidth; returns +// disconnect. +// +// Props mirror the DOM, not React: write real attribute names (class, tabindex, +// text-anchor, colspan…). Conveniences: `class`/`className` both set class, +// `style` accepts a string or a {prop: value} object, `on` adds a +// listener (name lowercased, so onPointerMove → 'pointermove'), boolean `true` +// renders a bare attribute and `false`/null/undefined skip it. Children may be +// nodes, strings/numbers (→ text nodes), arrays (flattened), or null (skipped) — +// text always goes through createTextNode, so untrusted names can never inject +// markup (the analog of React escaping JSX children). +// ============================================================================ +(function (SV) { + 'use strict'; + const SVG_NS = 'http://www.w3.org/2000/svg'; + + function build(el, props, children) { + if (props) { + for (const k in props) { + const v = props[k]; + if (v == null || v === false) continue; + if (k === 'class' || k === 'className') el.setAttribute('class', v); + else if (k === 'style' && v && typeof v === 'object') Object.assign(el.style, v); + else if (k.length > 2 && k[0] === 'o' && k[1] === 'n' && typeof v === 'function') { + el.addEventListener(k.slice(2).toLowerCase(), v); + } else if (v === true) el.setAttribute(k, ''); + else el.setAttribute(k, v); + } + } + append(el, children); + return el; + } + + function append(el, children) { + for (const c of children) { + if (c == null || c === false || c === true) continue; + if (Array.isArray(c)) append(el, c); + else if (c.nodeType) el.appendChild(c); + else el.appendChild(document.createTextNode(String(c))); + } + } + + /** + * Creates an HTML element (prop conventions are in the header above). + * @param {string} tag + * @param {object | null} [props] + * @param {...*} children Nodes, strings/numbers, arrays, or null. + * @returns {HTMLElement} + */ + function h(tag, props, ...children) { + return build(document.createElement(tag), props, children); + } + + /** + * Creates an SVG-namespaced element (prop conventions are in the header above). + * @param {string} tag e.g. 'svg', 'g', 'path' + * @param {object | null} [props] + * @param {...*} children Nodes, strings/numbers, arrays, or null. + * @returns {SVGElement} + */ + function svg(tag, props, ...children) { + return build(document.createElementNS(SVG_NS, tag), props, children); + } + + /** + * Creates a DocumentFragment holding the given children. + * @param {...*} children Nodes, strings/numbers, arrays, or null. + * @returns {DocumentFragment} + */ + function frag(...children) { + const f = document.createDocumentFragment(); + append(f, children); + return f; + } + + /** + * Removes all of an element's children. + * @param {Node} el + */ + function clear(el) { + while (el.firstChild) el.removeChild(el.firstChild); + } + + /** + * Measures el.clientWidth now (synchronously, pre-paint, like the React layout + * effect) and on every resize, calling cb(width) only when it changes. Charts use + * it to lay out at their true width without a 0-width flash and to reflow on + * container resize, with no window listener. + * @param {Element} el + * @param {(width: number) => void} cb + * @returns {() => void} Disconnects the observer. + */ + function observeWidth(el, cb) { + let last = -1; + const measure = () => { + const w = el.clientWidth; + if (w !== last) { + last = w; + cb(w); + } + }; + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + } + + Object.assign(SV, { h, svg, frag, clear, observeWidth }); +})((window.SV = window.SV || {})); diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/lib/format.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/lib/format.js new file mode 100644 index 00000000..19232a4c --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/lib/format.js @@ -0,0 +1,64 @@ +// ============================================================================ +// Number / percent / currency formatting, shared so KPI tiles, axes, and +// tooltips all read identically. +// ============================================================================ +(function (SV) { + 'use strict'; + /** Whole-dollar currency, e.g. "$2,326,534". */ + const fmtUSD0 = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }); + /** Currency with cents, e.g. "$57.19". */ + const fmtUSD2 = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + /** Integer with thousands separators, e.g. "38,654". */ + const fmtInt = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }); + + /** + * Formats a ratio as a percentage. + * @param {number} v Ratio (0.126 → "12.6%"). + * @param {number} [dp=1] Decimal places. + * @returns {string} + */ + function fmtPct(v, dp) { + return (v * 100).toFixed(dp == null ? 1 : dp) + '%'; + } + + /** + * M/D/YYYY from a timestamp, used by the Order Date range slider labels. Built + * from local Y/M/D so it round-trips with the slider's noon-anchored timestamps + * without a timezone shift. + * @param {number} t Timestamp in ms. + * @returns {string} The date, or a dash placeholder if `t` isn't a valid time. + */ + function fmtDateUS(t) { + const d = new Date(t); + if (isNaN(d)) return '—'; + return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`; + } + + /** + * Compact currency for axis ticks: "$5K", "$1.5K", "-$5K". Values under 1,000 + * use whole dollars. + * @param {number} v + * @returns {string} + */ + function fmtUSDk(v) { + const a = Math.abs(v); + if (a >= 1000) { + // keep one decimal for non-whole-thousand ticks (a 1500 gridline is "$1.5K", + // not the misleading "$2K"); sign leads the "$" so negatives read "-$5K". + const s = (a / 1000).toFixed(1).replace(/\.0$/, ''); + return (v < 0 ? '-$' : '$') + s + 'K'; + } + return fmtUSD0.format(v); + } + + Object.assign(SV, { fmtUSD0, fmtUSD2, fmtInt, fmtPct, fmtDateUS, fmtUSDk }); +})((window.SV = window.SV || {})); diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/lib/selection.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/lib/selection.js new file mode 100644 index 00000000..723571e9 --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/lib/selection.js @@ -0,0 +1,20 @@ +// Human-readable label for the current map selection (array of state names). +// One name verbatim, up to three comma-joined, otherwise a count. Shared by the +// selection chip and each monthly panel's title. +(function (SV) { + 'use strict'; + /** + * Human-readable label for the current map selection: one name verbatim, up to + * three comma-joined, otherwise a count. Shared by the selection chip and each + * monthly panel's title. + * @param {string[]} names Selected state names. + * @returns {string} + */ + function selectionLabel(names) { + if (names.length === 1) return names[0]; + if (names.length <= 3) return names.join(', '); + return names.length + ' states'; + } + + Object.assign(SV, { selectionLabel }); +})((window.SV = window.SV || {})); diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/lib/vds.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/lib/vds.js new file mode 100644 index 00000000..86b408f3 --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/lib/vds.js @@ -0,0 +1,390 @@ +// ============================================================================ +// VizQL Data Service query plumbing, filter builders, and the shared fetchers +// (Overview and Product tabs; the other tabs compose query() + the filter +// builders directly). These are framework-agnostic: each takes the live +// datasource handle plus the current scope ({ region, states, dateSel }) and +// resolves to plain data objects the views render. No DOM. +// +// ds.queryAsync takes the VDS shape { fields, filters }. Aggregated columns are +// aliased to stable keys; getVal falls back to the "FUNC(Caption)" key VDS uses +// when an alias is absent. +// ============================================================================ +(function (SV) { + 'use strict'; + var { CATEGORIES } = SV; + /** + * Reads an aggregated column from a VDS row. + * @param {object} row + * @param {string} alias The `fieldAlias` the query set. + * @param {string} [funcCap] The key VDS uses without an alias, e.g. `SUM(Sales)`. + * @returns {*} The value, or null if neither key is present. + */ + function getVal(row, alias, funcCap) { + if (row[alias] != null) return row[alias]; + if (funcCap != null && row[funcCap] != null) return row[funcCap]; + return null; + } + /** + * Coerces a VDS value to a number. + * @param {*} v + * @returns {number} 0 for null, missing, or non-numeric values. + */ + const num = (v) => Number(v) || 0; + + /** + * Region scope as VDS filters. + * @param {string | null} region A Region name, or null for all regions. + * @returns {object[]} One SET filter, or [] when unscoped. + */ + function regionFilters(region) { + return region + ? [{ field: { fieldCaption: 'Region' }, filterType: 'SET', values: [region] }] + : []; + } + /** + * Map-selection scope as VDS filters. + * @param {string[]} [states] Selected state names. + * @returns {object[]} One SET filter, or [] when nothing is selected. + */ + function stateFilters(states) { + const names = states || []; + return names.length + ? [{ field: { fieldCaption: 'State/Province' }, filterType: 'SET', values: names }] + : []; + } + /** + * Order Date scope as VDS filters: a QUANTITATIVE_DATE RANGE filter, the same + * wire shape ds.queryAsync accepts. A null or partial selection means "full + * range", so no filter is added and the query is unscoped by date. + * @param {{ minDate: string, maxDate: string } | null} dateSel Bounds as 'YYYY-MM-DD'. + * @returns {object[]} + */ + function dateFilters(dateSel) { + if (!dateSel || !dateSel.minDate || !dateSel.maxDate) return []; + return [ + { + field: { fieldCaption: 'Order Date' }, + filterType: 'QUANTITATIVE_DATE', + quantitativeFilterType: 'RANGE', + minDate: dateSel.minDate, + maxDate: dateSel.maxDate, + }, + ]; + } + /** + * Normalizes whatever VDS returns for a date column to a plain 'YYYY-MM-DD' + * string with no timezone drift: the leading date if already ISO-like, else the + * local Y/M/D of a parsed Date. + * @param {*} x + * @returns {string | null} null if `x` isn't a date. + */ + function isoDate(x) { + if (x == null) return null; + const m = String(x).match(/^(\d{4})-(\d{2})-(\d{2})/); + if (m) return `${m[1]}-${m[2]}-${m[3]}`; + const d = new Date(x); + if (isNaN(d)) return null; + const p = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; + } + /** + * Runs one VDS query through the Extensions API. + * @param {{ queryAsync: Function }} ds The Tableau datasource. + * @param {object[]} fields VDS fields: `{ fieldCaption, function?, fieldAlias? }`. + * @param {object[]} [filters] VDS filters. + * @returns {Promise} The result rows ([] if the response has none). + */ + function query(ds, fields, filters) { + return ds + .queryAsync({ fields, filters: filters || [] }) + .then((res) => (res && Array.isArray(res.data) ? res.data : [])); + } + + // ---- KPI headline row ------------------------------------------------------- + /** + * Headline KPIs for the current scope. + * @param {{ queryAsync: Function }} ds + * @param {{ region?: string, dateSel?: object }} [scope] + * @returns {Promise<{ sales: number, profit: number, quantity: number, discount: number, + * orders: number, customers: number, profitRatio: number, profitPerOrder: number, + * salesPerCustomer: number }>} + */ + function fetchKpis(ds, { region, dateSel } = {}) { + return query( + ds, + [ + { fieldCaption: 'Sales', function: 'SUM', fieldAlias: 'Sales' }, + { fieldCaption: 'Profit', function: 'SUM', fieldAlias: 'Profit' }, + { fieldCaption: 'Quantity', function: 'SUM', fieldAlias: 'Qty' }, + { fieldCaption: 'Discount', function: 'AVG', fieldAlias: 'Discount' }, + { fieldCaption: 'Order ID', function: 'COUNTD', fieldAlias: 'Orders' }, + { fieldCaption: 'Customer Name', function: 'COUNTD', fieldAlias: 'Customers' }, + ], + regionFilters(region).concat(dateFilters(dateSel)), + ).then((rows) => { + const r = rows[0] || {}; + const sales = num(getVal(r, 'Sales', 'SUM(Sales)')); + const profit = num(getVal(r, 'Profit', 'SUM(Profit)')); + const qty = num(getVal(r, 'Qty', 'SUM(Quantity)')); + const disc = num(getVal(r, 'Discount', 'AVG(Discount)')); + const orders = num(getVal(r, 'Orders', 'COUNTD(Order ID)')); + const customers = num(getVal(r, 'Customers', 'COUNTD(Customer Name)')); + return { + sales, + profit, + quantity: qty, + discount: disc, + orders, + customers, + profitRatio: sales ? profit / sales : 0, + profitPerOrder: orders ? profit / orders : 0, + salesPerCustomer: customers ? sales / customers : 0, + }; + }); + } + + // ---- per-state rollup for the choropleth ------------------------------------ + /** + * Per-state Sales and Profit for the choropleth. + * @param {{ queryAsync: Function }} ds + * @param {{ region?: string, dateSel?: object }} [scope] + * @returns {Promise>} Keyed by state name. + */ + function fetchMap(ds, { region, dateSel } = {}) { + return query( + ds, + [ + { fieldCaption: 'State/Province' }, + { fieldCaption: 'Sales', function: 'SUM', fieldAlias: 'Sales' }, + { fieldCaption: 'Profit', function: 'SUM', fieldAlias: 'Profit' }, + ], + regionFilters(region).concat(dateFilters(dateSel)), + ).then((rows) => { + const byState = Object.create(null); + rows.forEach((r) => { + const name = r['State/Province']; + if (name == null) return; + const sales = num(getVal(r, 'Sales', 'SUM(Sales)')); + const profit = num(getVal(r, 'Profit', 'SUM(Profit)')); + byState[name] = { name, sales, profit, ratio: sales ? profit / sales : 0 }; + }); + return byState; + }); + } + + // ---- monthly Sales + Profit grouped by a facet dimension -------------------- + /** + * Monthly Sales and Profit grouped by a facet dimension (the Overview's small + * multiples). + * @param {{ queryAsync: Function }} ds + * @param {{ region?: string, states?: string[], dateSel?: object }} [scope] + * @param {string} dimCaption The facet field, e.g. 'Segment' or 'Category'. + * @param {string[]} facetOrder Preferred facet order; unlisted facets follow it. + * @returns {Promise<{ order: string[], facets: Object, minT: number, maxT: number, maxSales: number, minProfit: number }>} + * Each facet's points are sorted by month (`t`, in ms). `minProfit` is ≤ 0 so + * the y-domain reaches loss months. + */ + function fetchMonthly(ds, { region, states, dateSel } = {}, dimCaption, facetOrder) { + return query( + ds, + [ + { fieldCaption: 'Order Date', function: 'TRUNC_MONTH', fieldAlias: 'Month' }, + { fieldCaption: dimCaption }, + { fieldCaption: 'Sales', function: 'SUM', fieldAlias: 'Sales' }, + { fieldCaption: 'Profit', function: 'SUM', fieldAlias: 'Profit' }, + ], + regionFilters(region).concat(stateFilters(states)).concat(dateFilters(dateSel)), + ).then((rows) => { + const facets = Object.create(null); + let minT = Infinity, + maxT = -Infinity; + rows.forEach((r) => { + const facet = r[dimCaption]; + const mStr = getVal(r, 'Month', 'TRUNC_MONTH(Order Date)'); + if (facet == null || mStr == null) return; + const t = new Date(mStr).getTime(); + if (isNaN(t)) return; + if (t < minT) minT = t; + if (t > maxT) maxT = t; + (facets[facet] || (facets[facet] = [])).push({ + t, + sales: num(getVal(r, 'Sales', 'SUM(Sales)')), + profit: num(getVal(r, 'Profit', 'SUM(Profit)')), + }); + }); + const order = facetOrder.filter((f) => facets[f]); + Object.keys(facets).forEach((f) => { + if (order.indexOf(f) < 0) order.push(f); + }); + order.forEach((f) => facets[f].sort((a, b) => a.t - b.t)); + // Sales is always ≥0; Profit can go negative (loss months), so track the + // most-negative profit too — the panel's y-domain must reach below zero or + // loss months get drawn under the baseline and clipped by the SVG. + let maxSales = 0, + minProfit = 0; + order.forEach((f) => { + facets[f].forEach((p) => { + if (p.sales > maxSales) maxSales = p.sales; + if (p.profit < minProfit) minProfit = p.profit; + }); + }); + return { order, facets, minT, maxT, maxSales, minProfit }; + }); + } + + // ---- Product tab: Sales by Category × Year × Month (highlight table) -------- + /** + * Product tab highlight table: one query row per (Category, month), pivoted + * client-side into Category → Year rows of 12 month cells. + * @param {{ queryAsync: Function }} ds + * @param {{ region?: string, dateSel?: object }} [scope] + * @returns {Promise<{ rows: { category: string, year: number, firstOfCat: boolean, + * catSpan: number, cells: ({ sales: number, profit: number } | null)[] }[], + * minSales: number, maxSales: number }>} `minSales`/`maxSales` span the whole + * table, so the sequential color ramp has a stable domain. + */ + function fetchCategorySales(ds, { region, dateSel } = {}) { + return query( + ds, + [ + { fieldCaption: 'Category' }, + { fieldCaption: 'Order Date', function: 'TRUNC_MONTH', fieldAlias: 'Month' }, + { fieldCaption: 'Sales', function: 'SUM', fieldAlias: 'Sales' }, + { fieldCaption: 'Profit', function: 'SUM', fieldAlias: 'Profit' }, + ], + regionFilters(region).concat(dateFilters(dateSel)), + ).then((rows) => { + const byCat = Object.create(null); // category -> { year -> [12] cells } + let minSales = Infinity, + maxSales = -Infinity; + rows.forEach((r) => { + const cat = r.Category; + const mStr = getVal(r, 'Month', 'TRUNC_MONTH(Order Date)'); + if (cat == null || mStr == null) return; + const d = new Date(mStr); + if (isNaN(d)) return; + const sales = num(getVal(r, 'Sales', 'SUM(Sales)')); + const profit = num(getVal(r, 'Profit', 'SUM(Profit)')); + const years = byCat[cat] || (byCat[cat] = Object.create(null)); + const cells = years[d.getFullYear()] || (years[d.getFullYear()] = new Array(12).fill(null)); + cells[d.getMonth()] = { sales, profit }; + if (sales < minSales) minSales = sales; + if (sales > maxSales) maxSales = sales; + }); + const cats = CATEGORIES.filter((c) => byCat[c]); + Object.keys(byCat).forEach((c) => { + if (cats.indexOf(c) < 0) cats.push(c); + }); + const out = []; + cats.forEach((cat) => { + const years = Object.keys(byCat[cat]) + .map(Number) + .sort((a, b) => a - b); + years.forEach((yr, i) => { + out.push({ + category: cat, + year: yr, + firstOfCat: i === 0, + catSpan: years.length, + cells: byCat[cat][yr], + }); + }); + }); + if (!isFinite(minSales)) { + minSales = 0; + maxSales = 0; + } + return { rows: out, minSales, maxSales }; + }); + } + + // ---- Product tab: per-product Sales + Profit (strip plot) ------------------- + /** + * Product tab strip plot: one point per (Category, Sub-Category, Segment, + * Product Name), positioned by Sales and colored by profit ratio. + * @param {{ queryAsync: Function }} ds + * @param {{ region?: string, dateSel?: object }} [scope] + * @returns {Promise<{ pts: { category: string, subCategory: string, segment: string, + * name: string, sales: number, profit: number, ratio: number }[], maxSales: number }>} + * `maxSales` lets all three Segment columns share one x-domain. + */ + function fetchProductScatter(ds, { region, dateSel } = {}) { + return query( + ds, + [ + { fieldCaption: 'Category' }, + { fieldCaption: 'Sub-Category' }, + { fieldCaption: 'Segment' }, + { fieldCaption: 'Product Name' }, + { fieldCaption: 'Sales', function: 'SUM', fieldAlias: 'Sales' }, + { fieldCaption: 'Profit', function: 'SUM', fieldAlias: 'Profit' }, + ], + regionFilters(region).concat(dateFilters(dateSel)), + ).then((rows) => { + const pts = []; + let maxSales = 0; + rows.forEach((r) => { + const category = r.Category, + subCategory = r['Sub-Category'], + segment = r.Segment; + if (category == null || subCategory == null || segment == null) return; + const sales = num(getVal(r, 'Sales', 'SUM(Sales)')); + const profit = num(getVal(r, 'Profit', 'SUM(Profit)')); + if (sales > maxSales) maxSales = sales; + pts.push({ + category, + subCategory, + segment, + name: r['Product Name'], + sales, + profit, + ratio: sales ? profit / sales : 0, + }); + }); + return { pts, maxSales }; + }); + } + + // ---- Order Date domain (min/max) — seeds the date range slider -------------- + /** + * The Order Date domain, which seeds the date range slider. Never rejects. + * @param {{ queryAsync: Function }} ds + * @returns {Promise<{ min: string, max: string } | null>} 'YYYY-MM-DD' bounds, or + * null if unavailable. + */ + function loadDateRange(ds) { + return query( + ds, + [ + { fieldCaption: 'Order Date', function: 'MIN', fieldAlias: 'MinD' }, + { fieldCaption: 'Order Date', function: 'MAX', fieldAlias: 'MaxD' }, + ], + [], + ) + .then((rows) => { + const r = rows[0] || {}; + const min = isoDate(getVal(r, 'MinD', 'MIN(Order Date)')); + const max = isoDate(getVal(r, 'MaxD', 'MAX(Order Date)')); + return min && max ? { min, max } : null; + }) + .catch(() => null); + } + + Object.assign(SV, { + getVal, + num, + regionFilters, + stateFilters, + dateFilters, + isoDate, + query, + fetchKpis, + fetchMap, + fetchMonthly, + fetchCategorySales, + fetchProductScatter, + loadDateRange, + }); +})((window.SV = window.SV || {})); diff --git a/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/main.js b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/main.js new file mode 100644 index 00000000..2226bd46 --- /dev/null +++ b/Samples/DataApp/Tableau Superstore Data App/Packages/com.tableau.mcp.tableau-superstore-data-app/content/src/main.js @@ -0,0 +1,17 @@ +// Vanilla entry: no framework, no build. index.html loads every app script as a +// classic