PivotHead is a headlessPivot, powerful and flexible library for creating interactive pivot tables in JavaScript/Typescript applications. It provides a core engine for data manipulation and, in the future, will be compatible with wrappers for React, Vue, Svelte, and Angular, making it easy to integrate into applications built with these frameworks.
- Flexible data pivoting and aggregation
- Sorting capabilities
- Grouping data by multiple fields
- Column resizing
- Drag and drop for rows
- Conditional formatting
- Custom measures and formulas
- Responsive design
- Customizable styling
- React integration (Upcoming)
- Hide and show toolbar
- Export by pdf
- Provide local json file for pivoting
To install PivotHead, use npm or yarn:
npm install @mindfiredigital/pivothead
import { PivotEngine } from '@mindfiredigital/pivothead';
const data = [
{
date: '2024-01-01',
product: 'Widget A',
region: 'North',
sales: 1000,
quantity: 50,
},
// ... more data
];
const config = {
data: data,
rows: [{ uniqueName: 'product', caption: 'Product' }],
columns: [{ uniqueName: 'region', caption: 'Region' }],
measures: [
{
uniqueName: 'sales',
caption: 'Total Sales',
aggregation: 'sum',
format: {
type: 'currency',
currency: 'USD',
locale: 'en-US',
decimals: 2,
},
},
{
uniqueName: 'quantity',
caption: 'Total Quantity',
aggregation: 'sum',
format: {
type: 'number',
decimals: 2,
locale: 'en-US',
},
},
],
dimensions: [
{ field: 'product', label: 'Product', type: 'string' },
{ field: 'region', label: 'Region', type: 'string' },
{ field: 'date', label: 'Date', type: 'date' },
{ field: 'sales', label: 'Sales', type: 'number' },
{ field: 'quantity', label: 'Quantity', type: 'number' },
],
defaultAggregation: 'sum',
isResponsive: true,
groupConfig: {
rowFields: ['product'],
columnFields: ['region'],
grouper: (item, fields) => fields.map(field => item[field]).join(' - '),
},
formatting: {
sales: {
type: 'currency',
currency: 'USD',
locale: 'en-US',
decimals: 2,
},
quantity: {
type: 'number',
decimals: 2,
locale: 'en-US',
},
},
conditionalFormatting: [
{
value: {
type: 'Number',
operator: 'Greater than',
value1: '1000',
value2: '',
},
format: {
font: 'Arial',
size: '14px',
color: '#ffffff',
backgroundColor: '#4CAF50',
},
},
// ... more conditional formatting rules
],
// Add initial sort configuration
initialSort: [
{
field: 'sales',
direction: 'desc',
type: 'measure',
aggregation: 'sum',
},
],
};
const engine = new PivotEngine(config);
// Use the engine to render your pivot tableThe PivotEngine class is the core of the PivotHead library. Here are its key methods:
constructor(config: PivotTableConfig<T>)Creates a new instance of PivotEngine with the given configuration.
getState(): PivotTableState<T>Example :-
-
getState(): PivotTableState
Returns the current state of the pivot table.
const state = engine.getState(); console.log(state.data); // Logs the current data array console.log(state.sortConfig); // Logs the current sort configuration
Returns the current state of the pivot table.
Resets the pivot table to its initial state.
reset();Example :-
-
reset()
engine.reset(); const state = engine.getState(); console.log(state); // Logs the initial state
Resets the pivot table to its initial state.
setMeasures(measureFields: MeasureConfig[])Sets the measures for the pivot table.
setDimensions(dimensionFields: Dimension[])Sets the dimensions for the pivot table.
setAggregation(type: AggregationType)Sets the aggregation type for the pivot table.
formatValue(value: any, field: string): stringFormats a value based on the specified field's format configuration.
Example:
const formattedValue = engine.formatValue(1000, 'sales');
console.log(formattedValue); // "$1,000.00"sort(field: string, direction: 'asc' | 'desc')Sorts the pivot table data.
Example:
engine.sort('sales', 'asc');setGroupConfig(groupConfig: GroupConfig | null)Sets the group configuration for the pivot table.
getGroupedData(): Group[]Returns the grouped data.
resizeRow(index: number, height: number)Resizes a specific row in the pivot table.
toggleRowExpansion(rowId: string)Toggles the expansion state of a row.
isRowExpanded(rowId: string): booleanChecks if a specific row is expanded.
dragRow(fromIndex: number, toIndex: number)Handles dragging a row to a new position.
dragColumn(fromIndex: number, toIndex: number)Handles dragging a column to a new position.
PivotHead supports sorting for measures and dimensions. You can configure initial sorting and handle sorting dynamically.
Example configuration:
const config = {
// ... other configuration options
initialSort: [
{
field: 'sales',
direction: 'desc',
type: 'measure',
aggregation: 'sum',
},
],
// ... other configuration options
};To handle sorting dynamically, you can use the sort method:
engine.sort('sales', 'asc');PivotHead supports conditional formatting for cells like decimal values , currency symbol etc.
Example configuration:
const config = {
// ... other configuration options
measures: [
{
uniqueName: 'sales',
caption: 'Total Sales',
aggregation: 'sum',
format: {
type: 'currency',
currency: 'USD',
locale: 'en-US',
decimals: 4,
},
},
{
uniqueName: 'quantity',
caption: 'Total Quantity',
aggregation: 'sum',
format: {
type: 'number',
decimals: 2,
locale: 'en-US',
},
},
{
uniqueName: 'averageSale',
caption: 'Average Sale',
aggregation: 'avg',
format: {
type: 'currency',
currency: 'USD',
locale: 'en-US',
decimals: 4,
},
formula: item => item.sales / item.quantity,
},
],
// ... other configuration options
formatting: {
sales: {
type: 'currency',
currency: 'USD',
locale: 'en-US',
decimals: 4,
},
quantity: {
type: 'number',
// decimals: 2,
// locale: 'en-US'
},
averageSale: {
type: 'currency',
currency: 'USD',
locale: 'en-US',
decimals: 4,
},
},
};PivotHead supports conditional formatting, allowing you to apply custom styles to cells based on their values.
Example configuration:
const config = {
// ... other configuration options
conditionalFormatting: [
{
value: {
type: 'Number',
operator: 'Greater than',
value1: '1000',
value2: '',
},
format: {
font: 'Arial',
size: '14px',
color: '#ffffff',
backgroundColor: '#4CAF50',
},
},
// ... more conditional formatting rules
],
};You can define custom measures with specific formulas:
const config = {
// ... other configuration options
measures: [
{
uniqueName: 'averageSale',
caption: 'Average Sale',
aggregation: 'avg',
format: {
type: 'currency',
currency: 'USD',
locale: 'en-US',
decimals: 2,
},
formula: item => item.sales / item.quantity,
},
],
};Show hide the hide the visibility of tool using config.
const config = {
// ... other configuration options
toolbar: <boolean>
// ... other configuration options
};The PivotTableConfig object allows you to customize various aspects of the pivot table:
interface PivotTableConfig<T> {
data: T[];
rows: { uniqueName: string; caption: string }[];
columns: { uniqueName: string; caption: string }[];
measures: MeasureConfig[];
dimensions: Dimension[];
defaultAggregation?: AggregationType;
isResponsive?: boolean;
groupConfig?: GroupConfig;
formatting?: Record<string, FormatConfig>;
conditionalFormatting?: ConditionalFormattingRule[];
}For detailed information on each configuration option, please refer to the source code and comments.
To run the examples:
- Clone the repository
- Navigate to the
examples/vanilla-js-demofolder - Install dependencies with
npm installoryarn install - Build the project with
npm run buildoryarn build - Start the development server with
npm startoryarn start - Open your browser and navigate to the local host address provided
These examples demonstrate various features of the PivotHead library, including:
- Basic pivot table setup
- Custom measures and formulas
- Grouping and aggregation
- Conditional formatting
- Drag and drop functionality
- Responsive design
For more detailed examples and usage scenarios, please refer to the example files in the repository.
We welcome contributions from the community. If you'd like to contribute to the pivothead npm package, please follow our Contributing Guidelines.
Copyright (c) Mindfire Digital llp. All rights reserved.
Licensed under the MIT license.
Explored WebLLM (@mlc-ai/web-llm) for in-browser
LLM inference using WebGPU/WebAssembly with no backend required 2. Package Architecture Design — Designed a new separate package
@mindfiredigital/pivothead-llm with core modules: LLMEngine, ModelManager,
PromptBuilder, and ActionParser 3. Action Schema Definition — Defined structured action types the LLM will
return (filter, sort, groupBy, topN, aggregate, export, switchTab, answer)
mapped to existing PivotEngine API calls 4. Package Scaffolding & Initial Implementation — Setting up packages/llm/
with package.json, tsconfig, tsup build config and implementing the base
LLMEngine class with model loading and query pipeline
Create the following files. Do not create any other files.
packages/llm/
├── src/
│ ├── index.ts
│ ├── types.ts
│ ├── LLMEngine.ts
│ ├── ModelManager.ts
│ ├── PromptBuilder.ts
│ ├── ActionParser.ts
│ └── ActionExecutor.ts
├── package.json
├── tsconfig.json
└── tsup.config.ts
package.json
{
"name": "@mindfiredigital/pivothead-llm",
"version": "1.0.0",
"description": "In-browser AI assistant for PivotHead powered by WebLLM (WebGPU)",
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"files": ["dist"],
"scripts": {
"build": "tsup",
"dev": "tsup --watch"
},
"dependencies": {
"@mlc-ai/web-llm": "^0.2.0"
},
"peerDependencies": {
"@mindfiredigital/pivothead": ">=1.5.0",
"@mindfiredigital/pivothead-analytics": ">=1.0.0"
},
"peerDependenciesMeta": {
"@mindfiredigital/pivothead-analytics": { "optional": true }
},
"devDependencies": {
"typescript": "^5.0.0",
"tsup": "^8.0.0"
}
}tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
clean: true,
sourcemap: true,
treeshake: true,
});tsconfig.json
{
"extends": "../tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"strict": true,
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler"
},
"include": ["src"]
}Define every type the package uses. No implementation here, types only.
// ----- Model loading -----
export interface LLMEngineOptions {
/** Default: 'Llama-3.2-3B-Instruct-q4f16_1-MLC' */
model?: string;
/** Called immediately on instantiation with WebGPU availability result */
onCapability?: (report: CapabilityReport) => void;
/** Max conversation turns to keep in history. Default: 10 */
maxHistory?: number;
}
export interface CapabilityReport {
webgpu: boolean;
message: string;
}
export interface LoadProgress {
/** 0–1 */
progress: number;
text: string;
stage: 'downloading' | 'initializing' | 'ready';
}
// ----- Pivot context -----
export interface FieldSchema {
name: string;
type: 'string' | 'number' | 'date';
/** Distinct values — only for low-cardinality string fields */
values?: string[];
}
export interface PivotState {
filters: Array<{ field: string; operator: string; value: unknown }>;
groupBy?: string;
sortBy?: { field: string; direction: 'asc' | 'desc' };
}
export interface PivotContext {
fields: FieldSchema[];
/** Up to 5 raw rows from the dataset */
sampleRows: Record<string, unknown>[];
/** Top 10 rows from current aggregated pivot output */
pivotOutput: Record<string, unknown>[];
currentState: PivotState;
}
// ----- Actions (discriminated union) -----
export interface FilterAction {
type: 'filter';
field: string;
operator: string;
value: unknown;
}
export interface RemoveFilterAction {
type: 'removeFilter';
field: string;
}
export interface SortAction {
type: 'sort';
field: string;
direction: 'asc' | 'desc';
}
export interface GroupByAction {
type: 'groupBy';
field: string;
}
export interface TopNAction {
type: 'topN';
n: number;
measure: string;
order: 'asc' | 'desc';
}
export interface AggregateAction {
type: 'aggregate';
field: string;
func: 'sum' | 'avg' | 'count' | 'min' | 'max';
}
export interface ResetAllAction {
type: 'resetAll';
}
export interface ExportAction {
type: 'export';
format: 'csv' | 'json' | 'pdf';
}
export interface SwitchTabAction {
type: 'switchTab';
tab: string;
}
export interface ChartTypeAction {
type: 'chartType';
chartType: string;
}
export interface AnswerAction {
type: 'answer';
text: string;
}
export interface ClarifyAction {
type: 'clarify';
question: string;
}
export interface ErrorAction {
type: 'error';
message: string;
}
export type PivotAction =
| FilterAction
| RemoveFilterAction
| SortAction
| GroupByAction
| TopNAction
| AggregateAction
| ResetAllAction
| ExportAction
| SwitchTabAction
| ChartTypeAction
| AnswerAction
| ClarifyAction
| ErrorAction;
// ----- Conversation -----
export interface ConversationMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
// ----- ActionExecutor -----
export interface ActionResult {
success: boolean;
description: string;
}
export interface ActionExecutorOptions {
pivotEngine: unknown; // @mindfiredigital/pivothead PivotEngine instance
chartEngine?: unknown; // @mindfiredigital/pivothead-analytics ChartEngine instance (optional)
onActionApplied?: (action: PivotAction, result: ActionResult) => void;
onError?: (action: PivotAction, error: Error) => void;
}Responsibilities: check WebGPU, load model, unload, expose engine handle.
import type { LoadProgress, CapabilityReport } from './types.js';
export class ModelManager {
private engine: unknown = null;
private _ready = false;
readonly model: string;
constructor(model: string) {
this.model = model;
}
/** Check navigator.gpu and return capability report */
checkWebGPU(): CapabilityReport {
// If navigator.gpu exists → { webgpu: true, message: 'WebGPU is supported' }
// Else → { webgpu: false, message: 'WebGPU is not supported in this browser. Please use Chrome 113+ or Edge 113+' }
}
/**
* Download and initialise the model.
* Throws if WebGPU is unavailable.
* Calls progressCallback throughout with { progress, text, stage }.
* Sets this._ready = true and stores engine when done.
*/
async load(progressCallback?: (p: LoadProgress) => void): Promise<void> {
// 1. checkWebGPU() — throw descriptive Error if not supported
// 2. import { CreateMLCEngine } from '@mlc-ai/web-llm'
// 3. Call CreateMLCEngine(this.model, { initProgressCallback })
// mapping web-llm progress events to LoadProgress shape
// 4. Set this.engine and this._ready = true
}
/** Release GPU memory */
async unload(): Promise<void> {
// Call (this.engine as any).unload() if available
// Set this.engine = null, this._ready = false
}
isReady(): boolean {
return this._ready;
}
/** Returns the raw web-llm engine for query calls */
getEngine(): unknown {
return this.engine;
}
}Responsibilities: given a PivotContext, produce the system prompt string injected before every query.
import type { PivotContext } from './types.js';
export class PromptBuilder {
build(context: PivotContext): string {
// Produce a string with these sections in order:
//
// 1. Role line:
// "You are a pivot table assistant. Answer questions or modify the table based on the data below."
//
// 2. Fields section — one line per field:
// "- <name> (<type>): <values joined by ', '>" ← include values only if present
// "- <name> (<type>)" ← no values line for number/date
//
// 3. Current pivot state section:
// "Grouped by: <groupBy | 'none'>"
// "Sorted by: <field> <direction | 'none'>"
// "Active filters: <JSON array | 'none'>"
//
// 4. Sample data (up to 5 rows) as a markdown table
//
// 5. Current pivot output (top 10 rows) as a markdown table
//
// 6. Instruction block (append verbatim):
// """
// When the user asks to modify the table, respond ONLY with a valid JSON object matching one of the action types.
// When the user asks a question about the data, respond with: {"type":"answer","text":"<your answer>"}
// If the query is ambiguous, respond with: {"type":"clarify","question":"<your clarifying question>"}
// If the query cannot be understood, respond with: {"type":"error","message":"<reason>"}
// Do not include any text outside the JSON object.
// """
}
}Responsibilities: extract and validate the JSON object from raw LLM text output.
import type { PivotAction } from './types.js';
const VALID_TYPES = [
'filter',
'removeFilter',
'sort',
'groupBy',
'topN',
'aggregate',
'resetAll',
'export',
'switchTab',
'chartType',
'answer',
'clarify',
'error',
] as const;
export class ActionParser {
/**
* Parse raw LLM string output into a PivotAction.
*
* Steps:
* 1. Extract first {...} block from the string (regex: /\{[\s\S]*?\}/)
* 2. JSON.parse it
* 3. Validate that parsed.type is in VALID_TYPES
* 4. Return the typed PivotAction
*
* On any failure (no JSON found, invalid JSON, unknown type):
* → return { type: 'error', message: '<descriptive reason>' }
*/
parse(raw: string): PivotAction {}
}Responsibilities: orchestrate ModelManager + PromptBuilder + ActionParser, manage conversation history, expose public methods.
import type {
LLMEngineOptions,
PivotContext,
PivotAction,
LoadProgress,
ConversationMessage,
} from './types.js';
import { ModelManager } from './ModelManager.js';
import { PromptBuilder } from './PromptBuilder.js';
import { ActionParser } from './ActionParser.js';
export class LLMEngine {
private manager: ModelManager;
private builder = new PromptBuilder();
private parser = new ActionParser();
private context: PivotContext | null = null;
private history: ConversationMessage[] = [];
private maxHistory: number;
constructor(options: LLMEngineOptions = {}) {
// 1. Set this.model = options.model ?? 'Llama-3.2-3B-Instruct-q4f16_1-MLC'
// 2. Set this.maxHistory = options.maxHistory ?? 10
// 3. Create this.manager = new ModelManager(model)
// 4. Run checkWebGPU() and call options.onCapability if provided
}
/** Download and initialise the model. Throws if WebGPU unavailable. */
async load(progressCallback?: (p: LoadProgress) => void): Promise<void> {
await this.manager.load(progressCallback);
}
/** Store the current pivot schema + state. Call after every data or state change. */
setContext(context: PivotContext): void {
this.context = context;
this.clearHistory(); // context change = new conversation
}
/**
* Send a natural language query. Returns a PivotAction.
*
* Steps:
* 1. Throw if !this.manager.isReady()
* 2. Build messages array:
* [{ role: 'system', content: this.builder.build(this.context) },
* ...this.history (last maxHistory * 2 messages),
* { role: 'user', content: text }]
* 3. Call web-llm engine.chat.completions.create({ messages, stream: false })
* 4. Extract assistant content string
* 5. Append user + assistant messages to this.history (trim to maxHistory * 2)
* 6. Parse with this.parser.parse(content) and return
*/
async query(text: string): Promise<PivotAction> {}
/**
* Streaming version of query(). Yields tokens as they arrive.
*
* Steps:
* 1. Throw if !this.manager.isReady()
* 2. Build messages array (same as query())
* 3. Call engine.chat.completions.create({ messages, stream: true })
* 4. For each chunk, yield the delta content string
* 5. After stream ends, append messages to history
*
* AbortSignal: if provided and aborted, break the loop and discard partial output.
*/
async *queryStream(
text: string,
signal?: AbortSignal
): AsyncGenerator<string> {}
/** Clear in-memory conversation history */
clearHistory(): void {
this.history = [];
}
/** Release GPU memory */
async unload(): Promise<void> {
await this.manager.unload();
}
/** Returns true if model is loaded and ready */
isReady(): boolean {
return this.manager.isReady();
}
}Responsibilities: take a PivotAction and call the correct method on the provided engine instances.
import type {
PivotAction,
ActionExecutorOptions,
ActionResult,
} from './types.js';
export class ActionExecutor {
constructor(private options: ActionExecutorOptions) {}
/**
* Execute a PivotAction against the registered engines.
*
* Mapping (call the method on options.pivotEngine unless noted):
*
* filter → pivotEngine.applyFilter({ field, operator, value })
* removeFilter → pivotEngine.removeFilter(field)
* sort → pivotEngine.sortData(field, direction)
* groupBy → pivotEngine.groupData(field)
* topN → pivotEngine.applyTopN(n, measure, order)
* aggregate → pivotEngine.setAggregation(field, func)
* resetAll → pivotEngine.reset()
* export → pivotEngine.export(format)
* switchTab → dispatch CustomEvent 'pivothead:switchTab' on window with { tab }
* chartType → options.chartEngine?.updateChartType(chartType) (no-op if no chartEngine)
* answer → call onActionApplied with description = action.text (no engine call)
* clarify → call onActionApplied with description = action.question (no engine call)
* error → call onError with new Error(action.message)
*
* Wrap each call in try/catch:
* - success → call onActionApplied(action, { success: true, description: humanReadable(action) })
* - failure → call onError(action, error)
*/
execute(action: PivotAction): void {}
/** Produce a human-readable one-line description of any action */
private humanReadable(action: PivotAction): string {
// Examples:
// filter → "Filter <field> <operator> <value>"
// sort → "Sort by <field> <direction>"
// topN → "Top <n> by <measure> (<order>)"
// groupBy → "Group by <field>"
// aggregate → "<field> aggregation → <func>"
// resetAll → "Reset all filters and groupings"
// export → "Export as <format>"
// switchTab → "Switch to <tab> tab"
// chartType → "Chart type → <chartType>"
// answer/clarify/error → the text/question/message field
}
}export { LLMEngine } from './LLMEngine.js';
export { ActionExecutor } from './ActionExecutor.js';
export type {
LLMEngineOptions,
CapabilityReport,
LoadProgress,
PivotContext,
FieldSchema,
PivotState,
PivotAction,
ActionResult,
ActionExecutorOptions,
ConversationMessage,
FilterAction,
RemoveFilterAction,
SortAction,
GroupByAction,
TopNAction,
AggregateAction,
ResetAllAction,
ExportAction,
SwitchTabAction,
ChartTypeAction,
AnswerAction,
ClarifyAction,
ErrorAction,
} from './types.js';pnpm --filter @mindfiredigital/pivothead-llm buildcompletes with zero errorsdist/index.js,dist/index.cjs,dist/index.d.tsare all generatednew LLMEngine()can be instantiated without a model loaded —isReady()returnsfalsenew LLMEngine({ onCapability })firesonCapabilitysynchronously in the constructorload()throws with message containing "WebGPU" whennavigator.gpuis absentActionParser.parse('{"type":"filter","field":"region","operator":"equals","value":"North"}')returns aFilterActionActionParser.parse('not json')returns{ type: 'error', message: ... }PromptBuilder.build(context)returns a string containing every field name from the context
File to modify:
examples/simple-js-demo/index.htmlandexamples/simple-js-demo/index.jsThe LLM package (Feature 1) must be built before this can be wired up.
<!-- Floating chat button -->
<button id="chat-toggle" aria-label="Open AI Assistant">💬</button>
<!-- Chat panel -->
<div id="chat-panel" class="chat-panel hidden">
<div class="chat-header">
<span>AI Assistant</span>
<button id="chat-clear" title="Clear chat">Clear</button>
<button id="chat-close" title="Close">✕</button>
</div>
<!-- Shown only while model is loading -->
<div id="chat-loading" class="hidden">
<div id="chat-progress-bar"><div id="chat-progress-fill"></div></div>
<div id="chat-progress-text">Loading model…</div>
</div>
<!-- WebGPU not supported message — replaces chat body -->
<div id="chat-no-webgpu" class="hidden">
<p>
WebGPU is not supported in this browser.<br />Please use Chrome 113+ or
Edge 113+.
</p>
</div>
<!-- Chat messages -->
<div id="chat-messages"></div>
<!-- Input row -->
<div class="chat-input-row">
<input
id="chat-input"
type="text"
placeholder="Ask something…"
autocomplete="off"
/>
<button id="chat-send">Send</button>
</div>
</div>#chat-toggle {
position: fixed;
bottom: 24px;
right: 24px;
width: 52px;
height: 52px;
border-radius: 50%;
font-size: 22px;
cursor: pointer;
z-index: 1000;
border: none;
background: #4f46e5;
color: #fff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
}
.chat-panel {
position: fixed;
bottom: 88px;
right: 24px;
width: 350px;
height: 520px;
z-index: 1000;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
display: flex;
flex-direction: column;
overflow: hidden;
transition:
opacity 0.2s,
transform 0.2s;
}
.chat-panel.hidden {
display: none;
}
.chat-header {
display: flex;
align-items: center;
padding: 12px 16px;
background: #4f46e5;
color: #fff;
gap: 8px;
}
.chat-header span {
flex: 1;
font-weight: 600;
}
.chat-header button {
background: none;
border: none;
color: #fff;
cursor: pointer;
font-size: 13px;
}
#chat-progress-bar {
height: 6px;
background: #e5e7eb;
margin: 12px 16px 4px;
border-radius: 3px;
}
#chat-progress-fill {
height: 100%;
background: #4f46e5;
border-radius: 3px;
width: 0%;
transition: width 0.3s;
}
#chat-progress-text {
font-size: 12px;
color: #6b7280;
padding: 0 16px 12px;
}
#chat-no-webgpu {
padding: 24px 16px;
color: #b91c1c;
font-size: 14px;
text-align: center;
}
#chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-bubble {
max-width: 82%;
padding: 8px 12px;
border-radius: 12px;
font-size: 14px;
line-height: 1.45;
}
.chat-bubble.user {
align-self: flex-end;
background: #4f46e5;
color: #fff;
border-bottom-right-radius: 2px;
}
.chat-bubble.assistant {
align-self: flex-start;
background: #f3f4f6;
color: #111;
border-bottom-left-radius: 2px;
}
.chat-bubble.system {
align-self: center;
background: #d1fae5;
color: #065f46;
font-size: 12px;
padding: 4px 10px;
border-radius: 20px;
}
.chat-input-row {
display: flex;
padding: 10px 12px;
gap: 8px;
border-top: 1px solid #e5e7eb;
}
#chat-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
outline: none;
}
#chat-send {
padding: 8px 16px;
background: #4f46e5;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
}Add a initChatPanel() function with this logic:
import { LLMEngine, ActionExecutor } from '@mindfiredigital/pivothead-llm';
async function initChatPanel() {
const llm = new LLMEngine({
onCapability(report) {
if (!report.webgpu) {
document.getElementById('chat-no-webgpu').classList.remove('hidden');
document.getElementById('chat-messages').classList.add('hidden');
document.getElementById('chat-input-row').classList.add('hidden');
}
},
});
const executor = new ActionExecutor({
pivotEngine, // existing pivotEngine variable in the demo
chartEngine, // existing chartEngine variable in the demo
onActionApplied(action, result) {
appendMessage('system', `✓ ${result.description}`);
},
onError(action, error) {
appendMessage('system', `✗ ${error.message}`);
},
});
let modelLoaded = false;
// Open / close panel
document.getElementById('chat-toggle').addEventListener('click', async () => {
const panel = document.getElementById('chat-panel');
panel.classList.toggle('hidden');
if (!panel.classList.contains('hidden') && !modelLoaded) {
document.getElementById('chat-loading').classList.remove('hidden');
try {
await llm.load(progress => {
document.getElementById('chat-progress-fill').style.width =
`${progress.progress * 100}%`;
document.getElementById('chat-progress-text').textContent =
progress.text;
});
modelLoaded = true;
} catch (e) {
appendMessage('system', e.message);
} finally {
document.getElementById('chat-loading').classList.add('hidden');
}
}
});
document.getElementById('chat-close').addEventListener('click', () => {
document.getElementById('chat-panel').classList.add('hidden');
});
document.getElementById('chat-clear').addEventListener('click', () => {
document.getElementById('chat-messages').innerHTML = '';
llm.clearHistory();
});
// Send message
async function sendMessage() {
const input = document.getElementById('chat-input');
const text = input.value.trim();
if (!text || !modelLoaded) return;
input.value = '';
appendMessage('user', text);
// Update LLM context with latest pivot state before every query
llm.setContext(buildLLMContext()); // see §2.4 below
const action = await llm.query(text);
if (action.type === 'answer') {
// Stream the answer token by token into a new assistant bubble
const bubble = appendMessage('assistant', '');
for await (const token of llm.queryStream(action.text)) {
bubble.textContent += token;
}
} else {
executor.execute(action);
}
}
document.getElementById('chat-send').addEventListener('click', sendMessage);
document.getElementById('chat-input').addEventListener('keydown', e => {
if (e.key === 'Enter') sendMessage();
});
}
function appendMessage(role, text) {
const bubble = document.createElement('div');
bubble.className = `chat-bubble ${role}`;
bubble.textContent = text;
const container = document.getElementById('chat-messages');
container.appendChild(bubble);
container.scrollTop = container.scrollHeight;
return bubble;
}
// Call on DOM ready
initChatPanel();Add this function in index.js. It reads the current state from the existing pivotEngine and formats it into a PivotContext object:
function buildLLMContext() {
const config = pivotEngine.getConfig(); // existing API
const data = pivotEngine.getProcessedData(); // existing API — array of row objects
// Infer field schemas from the first row
const firstRow = data[0] ?? {};
const fields = Object.keys(firstRow).map(name => {
const sample = firstRow[name];
const type =
typeof sample === 'number'
? 'number'
: !isNaN(Date.parse(sample))
? 'date'
: 'string';
// For string fields: collect distinct values (max 20)
const values =
type === 'string'
? [...new Set(data.map(r => r[name]).filter(Boolean))].slice(0, 20)
: undefined;
return { name, type, ...(values ? { values } : {}) };
});
return {
fields,
sampleRows: data.slice(0, 5),
pivotOutput: data.slice(0, 10),
currentState: {
filters: config.filters ?? [],
groupBy: config.groupBy,
sortBy: config.sortBy,
},
};
}- Chat button visible in bottom-right corner at all times
- Clicking the button opens the panel and begins model loading with visible progress bar
- After model is ready, user can type a query and receive a response
- Action queries (e.g. "sort by sales descending") update the table with a system confirmation message
- Answer queries (e.g. "which region has highest sales?") stream tokens into the assistant bubble
- "Clear" button wipes message history and resets conversation
- In a browser without WebGPU, the panel shows the no-WebGPU warning instead of the input
File:
packages/analytics/src/renderers/EChartsRenderer.tsFile:packages/analytics/src/renderers/PlotlyRenderer.tsFile:packages/analytics/src/renderers/D3Renderer.tsThese files exist as stubs. Implement therender()method in each.
Map PivotHead chart types to ECharts option objects and call chart.setOption(option).
| PivotHead type | ECharts type |
|---|---|
| column | bar (vertical) |
| bar | bar (horizontal, yAxis: { type: 'category' }) |
| line, area | line (areaStyle: {} for area) |
| pie, doughnut | pie (radius: ['40%','70%'] for doughnut) |
| scatter | scatter |
| heatmap | heatmap with visualMap |
| treemap | treemap |
| sankey | sankey |
| funnel | funnel |
| stacked column | bar with stack: 'total' |
| combo | mixed series types |
| histogram | bar with barCategoryGap: '0%' |
Map PivotHead chart types to Plotly.newPlot(container, data, layout) calls.
| PivotHead type | Plotly trace type |
|---|---|
| column | bar with orientation: 'v' |
| bar | bar with orientation: 'h' |
| line | scatter with mode: 'lines' |
| area | scatter with fill: 'tozeroy' |
| pie | pie |
| doughnut | pie with hole: 0.4 |
| scatter | scatter with mode: 'markers' |
| heatmap | heatmap |
| treemap | treemap |
| funnel | funnel |
| histogram | histogram |
| sankey | sankey |
| stacked column | multiple bar traces with barmode: 'stack' |
| combo | mixed trace array |
Each chart type is rendered by appending SVG elements to the container using D3 selections. Implement the following types at minimum:
column— vertical bar chart with axesbar— horizontal bar chart with axesline— line chart with path and axespie— pie chart usingd3.pie()+d3.arc()scatter— scatter plot with circle marks and axes
- Switching renderer via
ChartEngineoptions re-renders the same data using the new library without errors - All chart types listed in section 3.1–3.3 render without throwing
- Destroying a chart (
destroy()) removes all DOM nodes added by the renderer
Create packages/analytics/src/__tests__/ChartService.test.ts
// Test: getBarData returns datasets with correct labels and values
// Test: getHeatmapData returns { rowLabels, colLabels, values }
// Test: getHistogramData returns { binLabels, binCounts } with correct bin count
// Test: getFunnelData returns stages sorted descending by value
// Test: getPieData returns labels + data arrays of equal lengthCreate packages/analytics/src/__tests__/ChartJsRenderer.test.ts
// Mock canvas via jsdom + mock Chart.js
// Test: render('heatmap') does NOT call new Chart() — uses canvas2d directly
// Test: render('funnel') does NOT call new Chart() — uses canvas2d directly
// Test: render('column') DOES call new Chart()
// Test: destroy() removes the Chart instanceCreate packages/llm/src/__tests__/ActionParser.test.ts
// Test: valid filter JSON → FilterAction
// Test: valid topN JSON → TopNAction
// Test: invalid JSON → ErrorAction
// Test: valid JSON but unknown type → ErrorAction
// Test: JSON embedded in surrounding text → still parsed correctlyCreate packages/llm/src/__tests__/PromptBuilder.test.ts
// Test: output contains all field names from context
// Test: output contains distinct values for string fields
// Test: output contains 'none' when filters/groupBy/sortBy are empty
// Test: sample rows rendered as markdown tableCreate packages/llm/src/__tests__/ModelManager.test.ts
// Mock navigator.gpu = undefined
// Test: checkWebGPU() returns { webgpu: false, message: ... }
// Test: load() throws with message containing 'WebGPU'
// Mock navigator.gpu = {}
// Test: checkWebGPU() returns { webgpu: true, message: ... }- All tests pass with
pnpm --filter <package> test - Zero TypeScript errors in test files
- TypeScript strict mode on all new files
- No new external dependencies beyond those listed in each feature
- Do not modify any existing passing tests
- Do not modify
packages/core/— it is stable and published - Build command for the LLM package:
pnpm --filter @mindfiredigital/pivothead-llm build