Pivoting Overview
Summarize complex data relationships through pivoting and aggregation to reveal patterns and trends.
Pivots create dynamic columns from the unique cell values in a column. You can then apply aggregation measures to a value column to summarize data for each pivot column.
Pivot Reference Implementation
The demo below shows a complete, working pivoting example that you can use as a reference implementation. The remaining guides in the pivoting section focus on specific parts of the pivot configuration that LyteNyte Grid offers.
Complete Pivot Implementation
1import "@1771technologies/lytenyte-pro/pill-manager.css";2import "@1771technologies/lytenyte-pro/components.css";3import "@1771technologies/lytenyte-pro/light-dark.css";4import { salesData, type SaleDataItem } from "@1771technologies/grid-sample-data/sales-data";5import { computeField, Grid, useClientDataSource } from "@1771technologies/lytenyte-pro";6import {7 AgeGroup,8 CostCell,9 CountryCell,10 DateCell,11 DecimalCell,12 GenderCell,13 NumberCell,14 ProfitCell,15 StickGroupHeader,16 style,17} from "./components.jsx";18import { sum } from "es-toolkit";19import { useMemo, useState } from "react";20import { Menu, PillManager, RowGroupCell } from "@1771technologies/lytenyte-pro/components";21
22export interface GridSpec {23 readonly data: SaleDataItem;24 readonly column: {25 pivotable?: boolean;26 measurable?: boolean;27 measure?: string;28 };29}30
31export const columns: Grid.Column<GridSpec>[] = [32 { id: "date", name: "Date", cellRenderer: DateCell, width: 110 },33 { id: "age", name: "Age", type: "number", width: 120 },34 { id: "ageGroup", name: "Age Group", cellRenderer: AgeGroup, width: 110, pivotable: true },35 { id: "customerGender", name: "Gender", cellRenderer: GenderCell, width: 80, pivotable: true },36 { id: "country", name: "Country", cellRenderer: CountryCell, width: 150, pivotable: true },37
38 { id: "profit", name: "Profit", width: 120, type: "number", cellRenderer: ProfitCell, measurable: true },39 {40 id: "orderQuantity",41 name: "Quantity",42 type: "number",43 cellRenderer: DecimalCell,44 width: 120,45 measurable: true,46 },47 { id: "unitPrice", name: "Price", type: "number", width: 120, cellRenderer: NumberCell, measurable: true },48 { id: "cost", name: "Cost", width: 120, type: "number", cellRenderer: CostCell, measurable: true },49 { id: "revenue", name: "Revenue", width: 120, type: "number", cellRenderer: ProfitCell, measurable: true },50
51 { id: "state", name: "State", width: 150, pivotable: true },52 { id: "product", name: "Product", width: 160, pivotable: true },53 { id: "productCategory", name: "Category", width: 120, pivotable: true },54 { id: "subCategory", name: "Sub-Category", width: 160, pivotable: true },55];56
57const base: Grid.ColumnBase<GridSpec> = { width: 120, resizable: true };58
59const group: Grid.RowGroupColumn<GridSpec> = {60 cellRenderer: RowGroupCell,61 width: 200,62 pin: "start",63};64
65const aggSum: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {66 const values = data.map((x) => computeField<number>(field, x));67 return sum(values);68};69const aggAvg: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {70 const values = data.map((x) => computeField<number>(field, x));71 return sum(values) / values.length;72};73const aggMin: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {74 const values = data.map((x) => computeField<number>(field, x));75 return Math.min(...values);76};77const aggMax: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {78 const values = data.map((x) => computeField<number>(field, x));79 return Math.max(...values);80};81const aggCount: Grid.T.Aggregator<GridSpec["data"]> = (_, data) => {82 return data.length;83};84const aggFirst: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {85 for (let i = 0; i < data.length; i++) {86 const value = computeField(field, data[i]);87 if (value != null) return value;88 }89 return null;90};91const aggLast: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {92 for (let i = data.length - 1; i >= 0; i--) {93 const value = computeField(field, data[i]);94 if (value != null) return value;95 }96 return null;97};98
99export default function PivotDemo() {100 const [measures, setMeasures] = useState<PillManager.T.PillItem[]>(() => {101 return columns102 .filter((x) => x.measurable)103 .map<PillManager.T.PillItem>((x) => ({104 id: x.id,105 active: x.id === "profit",106 movable: true,107 name: x.name,108 }));109 });110
111 const [colPivots, setColPivots] = useState<PillManager.T.PillItem[]>(() =>112 columns113 .filter((x) => x.pivotable)114 .map((x) => ({115 id: x.id,116 name: x.name ?? x.id,117 active: x.id === "ageGroup",118 })),119 );120 const [rowPivots, setRowPivots] = useState<PillManager.T.PillItem[]>(() => {121 const pivotables = columns122 .filter((x) => x.pivotable)123 .map((x) => ({124 id: x.id,125 name: x.name ?? x.id,126 active: x.id === "country",127 }));128
129 const active = [...pivotables.filter((x) => x.active)];130 const notActive = [...pivotables.filter((x) => !x.active)];131
132 return [...active, ...notActive];133 });134
135 const ds = useClientDataSource<GridSpec>({136 data: salesData,137 pivotMode: true,138 pivotModel: {139 columns: colPivots.filter((x) => x.active),140 rows: rowPivots.filter((x) => x.active),141 measures: measures142 .filter((x) => x.active)143 .map((x) => {144 const column = columns.find((c) => x.id === c.id)!;145 return {146 dim: { ...column, measure: (x.data as string) ?? "sum" },147 fn: (x.data as string) ?? "sum",148 };149 }),150 },151 aggregateFns: {152 sum: aggSum,153 avg: aggAvg,154 min: aggMin,155 max: aggMax,156 first: aggFirst,157 last: aggLast,158 count: aggCount,159 },160 });161
162 const pillRows = useMemo<PillManager.T.PillRow[]>(() => {163 const colPivotPills = colPivots.map((x) => {164 return {165 id: x.id,166 active: x.active,167 movable: x.active,168 name: x.name ?? x.id,169 data: x,170 removable: true,171 };172 });173 const rowPivotPills = rowPivots.map((x) => {174 return {175 id: x.id,176 active: x.active,177 movable: x.active,178 name: x.name ?? x.id,179 data: x,180 removable: true,181 };182 });183 const measurePills = measures.map((x) => {184 return {185 id: x.id,186 active: x.active,187 name: x.name ?? x.id,188 data: x.data,189 movable: x.active,190 removable: true,191 };192 });193
194 return [195 {196 id: "column-pivots",197 label: "Column Pivots",198 type: "column-pivots",199 pills: colPivotPills,200 accepts: ["row-pivots"],201 },202 {203 id: "row-pivots",204 label: "Row Pivots",205 type: "row-pivots",206 pills: rowPivotPills,207 accepts: ["column-pivots"],208 },209 {210 id: "measures",211 label: "Measures",212 type: "measures",213 pills: measurePills,214 },215 ];216 }, [colPivots, measures, rowPivots]);217
218 const pivotProps = ds.usePivotProps();219
220 const [groupColumn, setGroupColumn] = useState(group);221 return (222 <>223 <div className="@container">224 <PillManager225 rows={pillRows}226 onPillItemActiveChange={(p) => {227 setColPivots((prev) => {228 const next = prev.map((x) =>229 x.id === p.item.id ? { ...x, active: p.item.active && p.row.id === "column-pivots" } : x,230 );231 return [...next.filter((x) => x.active), ...next.filter((x) => !x.active)];232 });233 setRowPivots((prev) => {234 const next = prev.map((x) =>235 x.id === p.item.id ? { ...x, active: p.item.active && p.row.id === "row-pivots" } : x,236 );237 return [...next.filter((x) => x.active), ...next.filter((x) => !x.active)];238 });239 setMeasures((prev) => {240 const next = prev.map((x) =>241 x.id === p.item.id ? { ...x, active: p.item.active && p.row.id === "measures" } : x,242 );243 return [...next.filter((x) => x.active), ...next.filter((x) => !x.active)];244 });245 }}246 onPillRowChange={(ev) => {247 for (const changed of ev.changed) {248 const activeFirst = changed.pills.filter((x) => x.active);249 const nonActive = changed.pills.filter((x) => !x.active);250 if (changed.id === "column-pivots") {251 setColPivots([...activeFirst, ...nonActive]);252 }253 if (changed.id === "row-pivots") {254 setRowPivots([...activeFirst, ...nonActive]);255 }256 if (changed.id === "measures") {257 setMeasures([...activeFirst, ...nonActive]);258 }259 }260 }}261 >262 {(row) => {263 return (264 <PillManager.Row row={row} className="relative">265 {row.id === "column-pivots" && (266 <SwapPivots267 onSwap={() => {268 setColPivots(rowPivots);269 setRowPivots(colPivots);270 }}271 />272 )}273 <PillManager.Label row={row} />274 <PillManager.Container>275 {row.pills.map((x) => {276 if (row.id === "measures") {277 return (278 <PillManager.Pill279 item={x}280 key={x.id}281 elementEnd={282 x.active ? (283 <div className="text-ln-primary-50 ms-1">284 <Menu>285 <Menu.Trigger286 onClick={(e) => e.stopPropagation()}287 className="text-ln-primary-50 hover:bg-ln-primary-30 cursor-pointer rounded-lg px-0.5 py-0.5 text-[10px]"288 >289 ({(x.data as string) ?? "sum"})290 </Menu.Trigger>291 <Menu.Popover>292 <Menu.Container>293 <Menu.Arrow />294
295 {["sum", "avg", "max", "min", "last", "first", "count"].map((agg) => {296 return (297 <Menu.Item298 key={agg}299 onAction={() => {300 setMeasures((prev) => {301 const next = [...prev];302 const nexIndex = prev.findIndex((m) => m.id === x.id);303 if (nexIndex === -1) return prev;304
305 next[nexIndex] = {306 ...next[nexIndex],307 data: agg.toLowerCase(),308 };309 return next;310 });311 }}312 >313 {agg}314 </Menu.Item>315 );316 })}317 </Menu.Container>318 </Menu.Popover>319 </Menu>320 </div>321 ) : null322 }323 />324 );325 }326
327 return <PillManager.Pill item={x} key={x.id} />;328 })}329 </PillManager.Container>330 <PillManager.Expander />331 </PillManager.Row>332 );333 }}334 </PillManager>335 </div>336 <div className="ln-grid" style={{ height: 500 }}>337 <Grid338 columns={columns}339 rowSource={ds}340 columnBase={base}341 rowGroupColumn={groupColumn}342 onRowGroupColumnChange={setGroupColumn}343 columnGroupRenderer={StickGroupHeader}344 {...pivotProps}345 styles={style}346 />347 </div>348 </>349 );350}351
352function SwapPivots({ onSwap }: { onSwap: () => void }) {353 return (354 <button355 className="bg-ln-gray-02 border-ln-border-strong text-ln-primary-50 hover:bg-ln-gray-10 z-1 @2xl:-bottom-2.5 @2xl:left-7 absolute -bottom-2 left-3 flex cursor-pointer items-center gap-1 rounded-2xl border px-1 py-0.5 text-[10px]"356 onClick={() => onSwap()}357 >358 <span>359 <svg width="12" height="11" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">360 <path361 d="M13.4819 6.09082L12.0422 7.53048L10.6025 6.09082"362 stroke="currentcolor"363 strokeWidth="1.5"364 strokeLinecap="round"365 strokeLinejoin="round"366 />367 <path368 d="M0.749587 7.42017L2.18925 5.98051L3.62891 7.42017"369 stroke="currentcolor"370 strokeWidth="1.5"371 strokeLinecap="round"372 strokeLinejoin="round"373 />374 <path375 d="M12.0962 7.34067C12.1038 7.22805 12.1077 7.11441 12.1077 6.99985C12.1077 4.261 9.88744 2.04072 7.14859 2.04072C5.7388 2.04072 4.46641 2.62899 3.5635 3.57345M2.23753 6.30658C2.20584 6.53312 2.18945 6.76457 2.18945 6.99985C2.18945 9.73871 4.40973 11.959 7.14859 11.959C8.68732 11.959 10.0624 11.2582 10.972 10.1583"376 stroke="currentcolor"377 strokeWidth="1.5"378 strokeLinecap="round"379 strokeLinejoin="round"380 />381 </svg>382 </span>383 <span className="sr-only">Swap row and column pivots</span>384 <span className="@2xl:block hidden">SWAP</span>385 </button>386 );387}1import type { Grid } from "@1771technologies/lytenyte-pro";2import type { GridSpec } from "./demo";3import { format, isValid, parse } from "date-fns";4import { countryFlags } from "@1771technologies/grid-sample-data/sales-data";5import { clsx, type ClassValue } from "clsx";6import { twMerge } from "tailwind-merge";7
8// Need to set header group overflow to unset to allow sticky header labels.9export const style: Grid.Style = { headerGroup: { style: { overflow: "unset" } } };10export function StickGroupHeader(props: Grid.T.HeaderGroupParams<GridSpec>) {11 return (12 <div style={{ position: "sticky", insetInlineStart: "calc(var(--ln-start-offset) + 8px)" }}>13 {props.groupPath.at(-1)}14 </div>15 );16}17
18export function DateCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {19 const field = api.columnField(column, row);20
21 if (typeof field !== "string") return "-";22
23 const dateField = parse(field as string, "MM/dd/yyyy", new Date());24
25 if (!isValid(dateField)) return "-";26
27 const niceDate = format(dateField, "yyyy MMM dd");28 return <div className="flex h-full w-full items-center tabular-nums">{niceDate}</div>;29}30
31export function AgeGroupPivotHeader({ column }: Grid.T.HeaderParams<GridSpec>) {32 const field = column.name ?? column.id;33
34 if (field === "Under 25") return <div className="text-[#944cec] dark:text-[#B181EB]">Under 25</div>;35 if (field === "25-34") return <div className="text-[#aa6c1a] dark:text-[#E5B474]">25-34</div>;36 if (field === "35-64") return <div className="text-[#0f7d4c] dark:text-[#52B086]">35-64</div>;37
38 if (field === "Grand Total") return "Grand Total";39
40 return "Other";41}42
43export function AgeGroup({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {44 const field = api.columnField(column, row);45
46 if (field === "Under 25") return <div className="text-[#944cec] dark:text-[#B181EB]">Under 25</div>;47 if (field === "25-34") return <div className="text-[#aa6c1a] dark:text-[#E5B474]">25-34</div>;48 if (field === "35-64") return <div className="text-[#0f7d4c] dark:text-[#52B086]">35-64</div>;49
50 return "-";51}52
53export function GenderCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {54 const field = api.columnField(column, row);55
56 if (field === "M")57 return (58 <div className="flex h-full w-full items-center gap-2">59 <div className="flex size-6 items-center justify-center rounded-full bg-blue-500/50">60 <span className="iconify ph--gender-male-bold size-4" />61 </div>62 M63 </div>64 );65
66 if (field === "F")67 return (68 <div className="flex h-full w-full items-center gap-2">69 <div className="flex size-6 items-center justify-center rounded-full bg-pink-500/50">70 <span className="iconify ph--gender-female-bold size-4" />71 </div>72 F73 </div>74 );75
76 return "-";77}78
79function tw(...c: ClassValue[]) {80 return twMerge(clsx(...c));81}82
83const formatter = new Intl.NumberFormat("en-US", {84 maximumFractionDigits: 0,85 minimumFractionDigits: 0,86});87export function ProfitCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {88 const field = api.columnField(column, row);89
90 if (typeof field !== "number") return "-";91
92 const prefix = column.measure === "count" ? "" : "$";93 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : prefix + formatter.format(field);94
95 return (96 <div97 className={tw(98 "flex h-full w-full items-center justify-end tabular-nums",99 prefix && field < 0 && "text-red-600 dark:text-red-300",100 prefix && field > 0 && "text-green-600 dark:text-green-300",101 )}102 >103 {formatted}104 </div>105 );106}107
108export function DecimalCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {109 const field = api.columnField(column, row);110
111 if (typeof field !== "number") return "-";112
113 const formatted = formatter.format(field);114
115 return <div className={"flex h-full w-full items-center justify-end tabular-nums"}>{formatted}</div>;116}117
118export function NumberCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {119 const field = api.columnField(column, row);120
121 if (typeof field !== "number") return "-";122
123 const prefix = column.measure === "count" ? "" : "$";124 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : prefix + formatter.format(field);125
126 return <div className={"flex h-full w-full items-center justify-end tabular-nums"}>{formatted}</div>;127}128
129export function CostCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {130 const field = api.columnField(column, row);131
132 if (typeof field !== "number") return "-";133
134 const prefix = column.measure === "count" ? "" : "$";135 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : prefix + formatter.format(field);136
137 return (138 <div139 className={tw(140 "flex h-full w-full items-center justify-end tabular-nums text-red-600 dark:text-red-300",141 )}142 >143 {formatted}144 </div>145 );146}147
148export function CountryCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {149 const field = api.columnField(column, row);150
151 const flag = countryFlags[field as keyof typeof countryFlags];152 if (!flag) return "-";153
154 return (155 <div className="flex h-full w-full items-center gap-2">156 <img className="size-4" src={flag} alt={`country flag of ${field}`} />157 <span>{String(field ?? "-")}</span>158 </div>159 );160}The remainder of this section explains how to enable basic pivots and provides the foundational knowledge required to understand the other guides in the pivoting section.
Pivot Mode
LyteNyte Grid does not maintain pivot state internally. It renders only the props provided, delegating pivot state management to the client row data source. To implement pivoting:
- Set the
pivotModeproperty totrueon the client data source. - Provide a
pivotModelvalue. - Call the
usePivotPropshook on the client data source and pass the returned props to the grid.
The code below shows the minimum configuration required.
1function PivotGrid() {2 const ds = useClientDataSource<Spec>({3 pivotMode: true,4 pivotModel: {},5 });6
7 const pivotProps = ds.usePivotProps();8
9 return <Grid {...pivotProps} />;10}Use the toggle below to enable pivot mode. The grid automatically pivots the Age Group values, groups rows by Country, and measures total Profit.
Basic Pivoting
1import "@1771technologies/lytenyte-pro/components.css";2import "@1771technologies/lytenyte-pro/light-dark.css";3import { salesData, type SaleDataItem } from "@1771technologies/grid-sample-data/sales-data";4import { computeField, Grid, useClientDataSource } from "@1771technologies/lytenyte-pro";5import {6 AgeGroup,7 AgeGroupPivotHeader,8 CostCell,9 CountryCell,10 DateCell,11 GenderCell,12 NumberCell,13 ProfitCell,14 StickGroupHeader,15 style,16 SwitchToggle,17} from "./components.js";18import { sum } from "es-toolkit";19import { useState } from "react";20import { RowGroupCell } from "@1771technologies/lytenyte-pro/components";21
22export interface GridSpec {23 readonly data: SaleDataItem;24}25
26export const columns: Grid.Column<GridSpec>[] = [27 { id: "date", name: "Date", cellRenderer: DateCell, width: 110 },28 { id: "country", name: "Country", cellRenderer: CountryCell, width: 150 },29 { id: "age", name: "Age", type: "number", width: 80 },30 { id: "ageGroup", name: "Age Group", cellRenderer: AgeGroup, width: 160 },31 { id: "customerGender", name: "Gender", cellRenderer: GenderCell, width: 120 },32 { id: "revenue", name: "Revenue", width: 80, type: "number", cellRenderer: ProfitCell },33 { id: "cost", name: "Cost", width: 80, type: "number", cellRenderer: CostCell },34 { id: "profit", name: "Profit", width: 80, type: "number", cellRenderer: ProfitCell },35 { id: "orderQuantity", name: "Quantity", type: "number", width: 60 },36 { id: "unitPrice", name: "Price", type: "number", width: 80, cellRenderer: NumberCell },37 { id: "productCategory", name: "Category", width: 120 },38 { id: "subCategory", name: "Sub-Category", width: 160 },39 { id: "product", name: "Product", width: 160 },40 { id: "state", name: "State", width: 150 },41];42
43const base: Grid.ColumnBase<GridSpec> = { width: 120, widthFlex: 1 };44
45const group: Grid.RowGroupColumn<GridSpec> = {46 cellRenderer: RowGroupCell,47 width: 200,48 pin: "start",49};50
51const aggSum: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {52 const values = data.map((x) => computeField<number>(field, x));53 return sum(values);54};55
56export default function PivotDemo() {57 const [pivotMode, setPivotMode] = useState(true);58 const ds = useClientDataSource<GridSpec>({59 data: salesData,60 pivotMode: pivotMode,61 pivotModel: {62 columns: [{ id: "ageGroup" }],63 rows: [{ id: "country" }],64 measures: [65 {66 dim: {67 ...columns.find((x) => x.id === "profit")!,68 headerRenderer: AgeGroupPivotHeader,69 width: 120,70 },71 fn: "sum",72 },73 ],74 },75
76 aggregateFns: {77 sum: aggSum,78 },79 });80
81 const pivotProps = ds.usePivotProps();82
83 return (84 <>85 <div className="border-ln-border border-b px-2 py-2">86 <SwitchToggle label="Pivot Mode" checked={pivotMode} onChange={setPivotMode} />87 </div>88 <div className="ln-grid" style={{ height: 500 }}>89 <Grid90 columns={columns}91 rowSource={ds}92 columnBase={base}93 rowGroupColumn={group}94 {...pivotProps}95 columnGroupRenderer={StickGroupHeader}96 styles={style}97 />98 </div>99 </>100 );101}1import type { Grid } from "@1771technologies/lytenyte-pro";2import type { GridSpec } from "./demo";3import { format, isValid, parse } from "date-fns";4import { countryFlags } from "@1771technologies/grid-sample-data/sales-data";5import { clsx, type ClassValue } from "clsx";6import { twMerge } from "tailwind-merge";7import { useId, type CSSProperties } from "react";8import { Switch } from "radix-ui";9
10// Need to set header group overflow to unset to allow sticky header labels.11export const style: Grid.Style = { headerGroup: { style: { overflow: "unset" } } };12export function StickGroupHeader(props: Grid.T.HeaderGroupParams<GridSpec>) {13 return (14 <div style={{ position: "sticky", insetInlineStart: "calc(var(--ln-start-offset) + 8px)" }}>15 {props.groupPath.at(-1)}16 </div>17 );18}19
20export function DateCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {21 const field = api.columnField(column, row);22
23 if (typeof field !== "string") return "-";24
25 const dateField = parse(field as string, "MM/dd/yyyy", new Date());26
27 if (!isValid(dateField)) return "-";28
29 const niceDate = format(dateField, "yyyy MMM dd");30 return <div className="flex h-full w-full items-center tabular-nums">{niceDate}</div>;31}32
33export function AgeGroupPivotHeader({ column }: Grid.T.HeaderParams<GridSpec>) {34 const field = column.name ?? column.id;35
36 if (field === "Youth (<25)") return <div className="text-[#944cec] dark:text-[#B181EB]">{field}</div>;37 if (field === "Young Adults (25-34)")38 return <div className="text-[#aa6c1a] dark:text-[#E5B474]">{field}</div>;39 if (field === "Adults (35-64)") return <div className="text-[#0f7d4c] dark:text-[#52B086]">{field}</div>;40
41 if (field === "Grand Total") return "Grand Total";42
43 return "Other";44}45
46export function AgeGroup({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {47 const field = api.columnField(column, row);48
49 if (field === "Youth (<25)") return <div className="text-[#944cec] dark:text-[#B181EB]">{field}</div>;50 if (field === "Young Adults (25-34)")51 return <div className="text-[#aa6c1a] dark:text-[#E5B474]">{field}</div>;52 if (field === "Adults (35-64)") return <div className="text-[#0f7d4c] dark:text-[#52B086]">{field}</div>;53
54 return "-";55}56
57export function GenderCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {58 const field = api.columnField(column, row);59
60 if (field === "Male")61 return (62 <div className="flex h-full w-full items-center gap-2">63 <div className="flex size-6 items-center justify-center rounded-full bg-blue-500/50">64 <span className="iconify ph--gender-male-bold size-4" />65 </div>66 Male67 </div>68 );69
70 if (field === "Female")71 return (72 <div className="flex h-full w-full items-center gap-2">73 <div className="flex size-6 items-center justify-center rounded-full bg-pink-500/50">74 <span className="iconify ph--gender-female-bold size-4" />75 </div>76 Female77 </div>78 );79
80 return "-";81}82
83function tw(...c: ClassValue[]) {84 return twMerge(clsx(...c));85}86
87const formatter = new Intl.NumberFormat("en-US", {88 maximumFractionDigits: 2,89 minimumFractionDigits: 0,90});91export function ProfitCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {92 const field = api.columnField(column, row);93
94 if (typeof field !== "number") return "-";95
96 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);97
98 return (99 <div100 className={tw(101 "flex h-full w-full items-center justify-end tabular-nums",102 field < 0 && "text-red-600 dark:text-red-300",103 field > 0 && "text-green-600 dark:text-green-300",104 )}105 >106 {formatted}107 </div>108 );109}110
111export function NumberCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {112 const field = api.columnField(column, row);113
114 if (typeof field !== "number") return "-";115
116 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);117
118 return <div className={"flex h-full w-full items-center justify-end tabular-nums"}>{formatted}</div>;119}120
121export function CostCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {122 const field = api.columnField(column, row);123
124 if (typeof field !== "number") return "-";125
126 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);127
128 return (129 <div130 className={tw(131 "flex h-full w-full items-center justify-end tabular-nums text-red-600 dark:text-red-300",132 )}133 >134 {formatted}135 </div>136 );137}138
139export function CountryCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {140 const field = api.columnField(column, row);141
142 const flag = countryFlags[field as keyof typeof countryFlags];143 if (!flag) return "-";144
145 return (146 <div className="flex h-full w-full items-center gap-2">147 <img className="size-4" src={flag} alt={`country flag of ${field}`} />148 <span>{String(field ?? "-")}</span>149 </div>150 );151}152
153export function SwitchToggle(props: { label: string; checked: boolean; onChange: (b: boolean) => void }) {154 const id = useId();155 return (156 <div className="flex items-center gap-2">157 <label className="text-ln-text-dark text-sm leading-none" htmlFor={id}>158 {props.label}159 </label>160 <Switch.Root161 className="bg-ln-gray-10 data-[state=checked]:bg-ln-primary-50 h-5.5 w-9.5 border-ln-border-strong relative cursor-pointer rounded-full border outline-none"162 id={id}163 checked={props.checked}164 onCheckedChange={(c) => props.onChange(c)}165 style={{ "-webkit-tap-highlight-color": "rgba(0, 0, 0, 0)" } as CSSProperties}166 >167 <Switch.Thumb className="size-4.5 block translate-x-0.5 rounded-full bg-white/95 shadow transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-4 data-[state=checked]:bg-white" />168 </Switch.Root>169 </div>170 );171}Rows are grouped by Country. Since pivoting presents data in a strictly aggregated state, these groups cannot be expanded to reveal individual sub-rows.
The code below shows the demo’s pivot configuration. The
grid uses the measures definition as a template for pivot-generated columns.
In this example, Profit provides the base column definition for the generated
pivot columns, and a header override ensures the new columns
display the corresponding Age Group values.
1const ds = useClientDataSource<GridSpec>({2 data: salesData,3 pivotMode: pivotMode,4 pivotModel: {5 columns: [{ id: "ageGroup" }],6 rows: [{ id: "country" }],7 measures: [8 {9 dim: {10 ...columns.find((x) => x.id === "profit")!,11 headerRenderer: AgeGroupPivotHeader,12 width: 120,13 },14 fn: "sum",15 },16 ],17 },18});Client Source Pivot Model
The pivotModel property of the useClientDataSource hook defines how LyteNyte Grid renders
pivoted rows. The interface is shown below.
1interface PivotModel<Spec extends GridSpec = GridSpec> {2 readonly columns?: (Column<Spec> | PivotField<Spec>)[];3 readonly rows?: (Column<Spec> | PivotField<Spec>)[];4 readonly measures?: { dim: Column<Spec>; fn: Aggregator<Spec["data"]> | string }[];5
6 readonly sort?: SortFn<Spec["data"]>;7 readonly filter?: (HavingFilterFn | null)[];8 readonly rowLabelFilter?: (LabelFilter | null)[];9 readonly colLabelFilter?: (LabelFilter | null)[];10}All properties in the pivot model are optional. The columns, rows, and measures properties
determine the structure of the pivot view. Their behavior is as follows:
- If you provide no values for
columns,rows, ormeasures, the pivot view will be blank. - If
columnscontains one or more entries, LyteNyte Grid uses the cell values from those columns to generate dynamic pivot columns. - If
rowscontains one or more entries, LyteNyte Grid groups the measure output by the specified rows. - If
measuresis provided, LyteNyte Grid aggregates grid rows using the defined measures. Measure behavior depends on which other properties are present:- If only
measuresare provided, LyteNyte Grid creates one column per measure and aggregates across all rows, producing a single totals row. - If
measuresandcolumnsare provided, LyteNyte Grid aggregates measures for each dynamically generated pivot column. - If
measuresandrowsare provided, LyteNyte Grid aggregates measures for each row group. - If
measures,rows, andcolumnsare all provided, LyteNyte Grid aggregates measures for each row group, split across the dynamically generated pivot columns.
- If only
Next Steps
- Row & Column Pivots: Configure row and column dimensions for pivot view.
- Pivot Filters: Filter pivots by labels or predicate conditions.
- Pivot Sorting: Customize the sort order of pivoted row groups.
