Measures
Configure measures to aggregate values across pivot rows and columns.
To create a measure, define a custom function that receives an array of leaf rows and a field value, then returns a summarized result. The return value can be any type, but it’s typically numeric.
Note
This guide assumes familiarity with row aggregations. See the Client Row Aggregations guide for details.
Defining Measures
Define a measure by setting the measure property on the pivot model. LyteNyte Grid uses a measure
for two purposes:
- Aggregate row data based on the row pivot and column pivot dimensions.
- If the measure dimension is a column, use it as a reference for the columns created from the pivot configuration.
The demo below shows a measure that sums Profit. Rows are pivoted by Country and columns by Age Group, creating a profit breakdown per country and age demographic.
Sum of Profit Measure
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 CostCell,8 CountryCell,9 DateCell,10 GenderCell,11 NumberCell,12 ProfitCell,13 StickGroupHeader,14 style,15} from "./components.jsx";16import { sum } from "es-toolkit";17import { RowGroupCell } from "@1771technologies/lytenyte-pro/components";18
19export interface GridSpec {20 readonly data: SaleDataItem;21 readonly column: {22 pivotable?: boolean;23 };24}25
26export const columns: Grid.Column<GridSpec>[] = [27 { id: "date", name: "Date", cellRenderer: DateCell, width: 110 },28 { id: "age", name: "Age", type: "number", width: 80 },29 { id: "ageGroup", name: "Age Group", cellRenderer: AgeGroup, width: 110, pivotable: true },30 { id: "customerGender", name: "Gender", cellRenderer: GenderCell, width: 80, pivotable: true },31 { id: "country", name: "Country", cellRenderer: CountryCell, width: 150, pivotable: true },32 { id: "orderQuantity", name: "Quantity", type: "number", width: 60 },33 { id: "unitPrice", name: "Price", type: "number", width: 80, cellRenderer: NumberCell },34 { id: "cost", name: "Cost", width: 80, type: "number", cellRenderer: CostCell },35 { id: "revenue", name: "Revenue", width: 80, type: "number", cellRenderer: ProfitCell },36 { id: "profit", name: "Profit", width: 80, type: "number", cellRenderer: ProfitCell },37 { id: "state", name: "State", width: 150, pivotable: true },38 { id: "product", name: "Product", width: 160, pivotable: true },39 { id: "productCategory", name: "Category", width: 120, pivotable: true },40 { id: "subCategory", name: "Sub-Category", width: 160, pivotable: true },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 ds = useClientDataSource<GridSpec>({58 data: salesData,59 pivotMode: true,60 pivotModel: {61 columns: [{ id: "ageGroup" }],62 rows: [{ id: "country" }],63 measures: [64 {65 dim: { id: "profit", name: "Profit", type: "number", cellRenderer: ProfitCell, width: 120 },66 fn: "sum",67 },68 ],69 },70 aggregateFns: { sum: aggSum },71 });72
73 const pivotProps = ds.usePivotProps();74 return (75 <div className="ln-grid" style={{ height: 500 }}>76 <Grid77 columns={columns}78 rowSource={ds}79 columnBase={base}80 rowGroupColumn={group}81 {...pivotProps}82 styles={style}83 columnGroupRenderer={StickGroupHeader}84 />85 </div>86 );87}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 === "Male")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 <svg width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">61 <path d="M216,28H168a12,12,0,0,0,0,24h19L154.28,84.74a84,84,0,1,0,17,17L204,69V88a12,12,0,0,0,24,0V40A12,12,0,0,0,216,28ZM146.41,194.46a60,60,0,1,1,0-84.87A60.1,60.1,0,0,1,146.41,194.46Z"></path>62 </svg>63 </div>64 Male65 </div>66 );67
68 if (field === "Female")69 return (70 <div className="flex h-full w-full items-center gap-2">71 <div className="flex size-6 items-center justify-center rounded-full bg-pink-500/50">72 <svg width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">73 <path d="M212,96a84,84,0,1,0-96,83.13V196H88a12,12,0,0,0,0,24h28v20a12,12,0,0,0,24,0V220h28a12,12,0,0,0,0-24H140V179.13A84.12,84.12,0,0,0,212,96ZM68,96a60,60,0,1,1,60,60A60.07,60.07,0,0,1,68,96Z"></path>74 </svg>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}The code below shows how to apply the measure. This demo uses a predefined measure function called
"sum", but you can also provide a function inline if that better suits your application’s
use case.
1const aggSum: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {2 const values = data.map((x) => computeField<number>(field, x));3 return sum(values);4};5
6const ds = useClientDataSource<GridSpec>({7 data: salesData,8 pivotMode: true,9 pivotModel: {10 columns: [{ id: "ageGroup" }],11 rows: [{ id: "country" }],12 measures: [13 {14 dim: { id: "profit", name: "Profit", type: "number", cellRenderer: ProfitCell, width: 120 },15 fn: "sum",16 },17 ],18 },19 aggregateFns: { sum: aggSum },20});Configuring Multiple Measures
You can apply multiple measures to the pivot configuration by providing additional dimensions. The order of measures does not affect aggregation behavior, but it does influence the initial column order when pivots are applied.
The demo below demonstrates multiple measures in action. You can also update the measure function by clicking the function name in the pill and selecting a different measure for that column.
Multiple Measures
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} from "./components.jsx";16import { sum } from "es-toolkit";17import { useMemo, useState } from "react";18import { Menu, PillManager, RowGroupCell } from "@1771technologies/lytenyte-pro/components";19
20export interface GridSpec {21 readonly data: SaleDataItem;22 readonly column: {23 measurable?: boolean;24 };25}26
27export const columns: Grid.Column<GridSpec>[] = [28 { id: "date", name: "Date", cellRenderer: DateCell, width: 110 },29 { id: "age", name: "Age", type: "number", width: 80 },30 { id: "ageGroup", name: "Age Group", cellRenderer: AgeGroup, width: 110 },31 { id: "customerGender", name: "Gender", cellRenderer: GenderCell, width: 80 },32 { id: "country", name: "Country", cellRenderer: CountryCell, width: 150 },33
34 { id: "revenue", name: "Revenue", width: 80, type: "number", cellRenderer: ProfitCell, measurable: true },35 { id: "profit", name: "Profit", width: 100, type: "number", cellRenderer: ProfitCell, measurable: true },36 { id: "unitPrice", name: "Price", type: "number", width: 80, cellRenderer: NumberCell, measurable: true },37 { id: "cost", name: "Cost", width: 80, type: "number", cellRenderer: CostCell, measurable: true },38 {39 id: "orderQuantity",40 name: "Quantity",41 type: "number",42 width: 80,43 measurable: true,44 cellRenderer: DecimalCell,45 },46
47 { id: "state", name: "State", width: 150 },48 { id: "product", name: "Product", width: 160 },49 { id: "productCategory", name: "Category", width: 120 },50 { id: "subCategory", name: "Sub-Category", width: 160 },51];52
53const base: Grid.ColumnBase<GridSpec> = { width: 130, widthFlex: 1 };54
55const group: Grid.RowGroupColumn<GridSpec> = {56 cellRenderer: RowGroupCell,57 width: 200,58 pin: "start",59};60
61const aggSum: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {62 const values = data.map((x) => computeField<number>(field, x));63 return sum(values);64};65const aggAvg: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {66 const values = data.map((x) => computeField<number>(field, x));67 return sum(values) / values.length;68};69const aggMin: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {70 const values = data.map((x) => computeField<number>(field, x));71 return Math.min(...values);72};73const aggMax: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {74 const values = data.map((x) => computeField<number>(field, x));75 return Math.max(...values);76};77
78export default function PivotDemo() {79 const [measures, setMeasures] = useState<PillManager.T.PillItem[]>(() => {80 return columns81 .filter((x) => x.measurable)82 .map<PillManager.T.PillItem>((x) => ({83 id: x.id,84 active: x.id === "profit" || x.id === "revenue",85 movable: true,86 name: x.name,87 }));88 });89
90 const ds = useClientDataSource<GridSpec>({91 data: salesData,92 pivotMode: true,93 pivotModel: {94 columns: [{ id: "ageGroup" }],95 rows: [{ id: "country" }],96 measures: measures97 .filter((x) => x.active)98 .map((x) => {99 const column = columns.find((c) => x.id === c.id)!;100 return {101 dim: column,102 fn: (x.data as string) ?? "sum",103 };104 }),105 },106 aggregateFns: { sum: aggSum, avg: aggAvg, min: aggMin, max: aggMax },107 });108
109 const pillRows = useMemo<PillManager.T.PillRow[]>(() => {110 const measurePills = measures.map((x) => {111 return {112 id: x.id,113 active: x.active,114 name: x.name ?? x.id,115 data: x.data,116 removable: true,117 };118 });119 return [120 {121 id: "measures",122 label: "Measures",123 type: "measures",124 pills: measurePills,125 },126 ];127 }, [measures]);128
129 const pivotProps = ds.usePivotProps();130 return (131 <>132 <PillManager133 rows={pillRows}134 onPillItemActiveChange={(p) => {135 setMeasures((prev) => {136 const next = prev.map((x) =>137 x.id === p.item.id ? { ...x, active: p.item.active && p.row.id === "measures" } : x,138 );139 return [...next.filter((x) => x.active), ...next.filter((x) => !x.active)];140 });141 }}142 onPillRowChange={(ev) => {143 for (const changed of ev.changed) {144 const activeFirst = changed.pills.filter((x) => x.active);145 const nonActive = changed.pills.filter((x) => !x.active);146 if (changed.id === "measures") {147 setMeasures([...activeFirst, ...nonActive]);148 }149 }150 }}151 >152 {(row) => {153 return (154 <PillManager.Row row={row}>155 <PillManager.Label row={row} />156 <PillManager.Container>157 {row.pills.map((x) => {158 return (159 <PillManager.Pill160 item={x}161 key={x.id}162 elementEnd={163 x.active ? (164 <div className="text-ln-primary-50 ms-1">165 <Menu>166 <Menu.Trigger167 onClick={(e) => e.stopPropagation()}168 className="text-ln-primary-50 hover:bg-ln-primary-30 cursor-pointer rounded-lg px-0.5 py-0.5 text-[10px]"169 >170 ({(x.data as string) ?? "sum"})171 </Menu.Trigger>172 <Menu.Popover>173 <Menu.Container>174 <Menu.Arrow />175
176 {["sum", "avg", "max", "min"].map((agg) => {177 return (178 <Menu.Item179 key={agg}180 onAction={() => {181 setMeasures((prev) => {182 const next = [...prev];183 const nexIndex = prev.findIndex((m) => m.id === x.id);184 if (nexIndex === -1) return prev;185
186 next[nexIndex] = { ...next[nexIndex], data: agg.toLowerCase() };187 return next;188 });189 }}190 >191 {agg}192 </Menu.Item>193 );194 })}195 </Menu.Container>196 </Menu.Popover>197 </Menu>198 </div>199 ) : null200 }201 />202 );203 })}204 </PillManager.Container>205 <PillManager.Expander />206 </PillManager.Row>207 );208 }}209 </PillManager>210 <div className="ln-grid" style={{ height: 500 }}>211 <Grid212 columns={columns}213 rowSource={ds}214 columnBase={base}215 rowGroupColumn={group}216 {...pivotProps}217 styles={{218 headerGroup: {219 style: { position: "sticky", insetInlineStart: "var(--ln-start-offset)", overflow: "unset" },220 },221 }}222 />223 </div>224 </>225 );226}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
8export function DateCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {9 const field = api.columnField(column, row);10
11 if (typeof field !== "string") return "-";12
13 const dateField = parse(field as string, "MM/dd/yyyy", new Date());14
15 if (!isValid(dateField)) return "-";16
17 const niceDate = format(dateField, "yyyy MMM dd");18 return <div className="flex h-full w-full items-center tabular-nums">{niceDate}</div>;19}20
21export function AgeGroupPivotHeader({ column }: Grid.T.HeaderParams<GridSpec>) {22 const field = column.name ?? column.id;23
24 if (field === "Under 25") return <div className="text-[#944cec] dark:text-[#B181EB]">Under 25</div>;25 if (field === "25-34") return <div className="text-[#aa6c1a] dark:text-[#E5B474]">25-34</div>;26 if (field === "35-64") return <div className="text-[#0f7d4c] dark:text-[#52B086]">35-64</div>;27
28 if (field === "Grand Total") return "Grand Total";29
30 return "Other";31}32
33export function AgeGroup({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {34 const field = api.columnField(column, row);35
36 if (field === "Under 25") return <div className="text-[#944cec] dark:text-[#B181EB]">Under 25</div>;37 if (field === "25-34") return <div className="text-[#aa6c1a] dark:text-[#E5B474]">25-34</div>;38 if (field === "35-64") return <div className="text-[#0f7d4c] dark:text-[#52B086]">35-64</div>;39
40 return "-";41}42
43export function GenderCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {44 const field = api.columnField(column, row);45
46 if (field === "Male")47 return (48 <div className="flex h-full w-full items-center gap-2">49 <div className="flex size-6 items-center justify-center rounded-full bg-blue-500/50">50 <svg width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">51 <path d="M216,28H168a12,12,0,0,0,0,24h19L154.28,84.74a84,84,0,1,0,17,17L204,69V88a12,12,0,0,0,24,0V40A12,12,0,0,0,216,28ZM146.41,194.46a60,60,0,1,1,0-84.87A60.1,60.1,0,0,1,146.41,194.46Z"></path>52 </svg>53 </div>54 Male55 </div>56 );57
58 if (field === "Female")59 return (60 <div className="flex h-full w-full items-center gap-2">61 <div className="flex size-6 items-center justify-center rounded-full bg-pink-500/50">62 <svg width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">63 <path d="M212,96a84,84,0,1,0-96,83.13V196H88a12,12,0,0,0,0,24h28v20a12,12,0,0,0,24,0V220h28a12,12,0,0,0,0-24H140V179.13A84.12,84.12,0,0,0,212,96ZM68,96a60,60,0,1,1,60,60A60.07,60.07,0,0,1,68,96Z"></path>64 </svg>65 </div>66 Female67 </div>68 );69
70 return "-";71}72
73function tw(...c: ClassValue[]) {74 return twMerge(clsx(...c));75}76
77const formatter = new Intl.NumberFormat("en-US", {78 maximumFractionDigits: 0,79 minimumFractionDigits: 0,80});81export function ProfitCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {82 const field = api.columnField(column, row);83
84 if (typeof field !== "number") return "-";85
86 const prefix = "$";87 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : prefix + formatter.format(field);88
89 return (90 <div91 className={tw(92 "flex h-full w-full items-center justify-end tabular-nums",93 prefix && field < 0 && "text-red-600 dark:text-red-300",94 prefix && field > 0 && "text-green-600 dark:text-green-300",95 )}96 >97 {formatted}98 </div>99 );100}101
102export function DecimalCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {103 const field = api.columnField(column, row);104
105 if (typeof field !== "number") return "-";106
107 const formatted = formatter.format(field);108
109 return <div className={"flex h-full w-full items-center justify-end tabular-nums"}>{formatted}</div>;110}111
112export function NumberCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {113 const field = api.columnField(column, row);114
115 if (typeof field !== "number") return "-";116
117 const prefix = "$";118 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : prefix + formatter.format(field);119
120 return <div className={"flex h-full w-full items-center justify-end tabular-nums"}>{formatted}</div>;121}122
123export function CostCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {124 const field = api.columnField(column, row);125
126 if (typeof field !== "number") return "-";127
128 const prefix = "$";129 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : prefix + formatter.format(field);130
131 return (132 <div133 className={tw(134 "flex h-full w-full items-center justify-end tabular-nums text-red-600 dark:text-red-300",135 )}136 >137 {formatted}138 </div>139 );140}141
142export function CountryCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {143 const field = api.columnField(column, row);144
145 const flag = countryFlags[field as keyof typeof countryFlags];146 if (!flag) return "-";147
148 return (149 <div className="flex h-full w-full items-center gap-2">150 <img className="size-4" src={flag} alt={`country flag of ${field}`} />151 <span>{String(field ?? "-")}</span>152 </div>153 );154}Be aware that measures multiply your column count. If a pivot generates 20 columns, two measures produce 40 columns, and three produce 60.
Multiple Measures Per Column
You can measure the same column multiple times to apply different measure functions.
The demo below measures Profit with both sum and avg. The grid treats each measure as distinct, even when both reference the same column.
Column Multiple Measures
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 GenderCell,12 NumberCell,13 ProfitCell,14 StickGroupHeader,15 style,16} from "./components.jsx";17import { sum } from "es-toolkit";18import { RowGroupCell } from "@1771technologies/lytenyte-pro/components";19
20export interface GridSpec {21 readonly data: SaleDataItem;22 readonly column: {23 measurable?: boolean;24 };25}26
27export const columns: Grid.Column<GridSpec>[] = [28 { id: "date", name: "Date", cellRenderer: DateCell, width: 110 },29 { id: "age", name: "Age", type: "number", width: 80 },30 { id: "ageGroup", name: "Age Group", cellRenderer: AgeGroup, width: 110 },31 { id: "customerGender", name: "Gender", cellRenderer: GenderCell, width: 80 },32 { id: "country", name: "Country", cellRenderer: CountryCell, width: 150 },33
34 { id: "profit", name: "Profit", width: 100, type: "number", cellRenderer: ProfitCell, measurable: true },35 { id: "orderQuantity", name: "Quantity", type: "number", width: 60, measurable: true },36 { id: "unitPrice", name: "Price", type: "number", width: 80, cellRenderer: NumberCell, measurable: true },37 { id: "cost", name: "Cost", width: 80, type: "number", cellRenderer: CostCell, measurable: true },38 { id: "revenue", name: "Revenue", width: 80, type: "number", cellRenderer: ProfitCell, measurable: true },39
40 { id: "state", name: "State", width: 150 },41 { id: "product", name: "Product", width: 160 },42 { id: "productCategory", name: "Category", width: 120 },43 { id: "subCategory", name: "Sub-Category", width: 160 },44];45
46const base: Grid.ColumnBase<GridSpec> = { width: 130, widthFlex: 1 };47
48const group: Grid.RowGroupColumn<GridSpec> = {49 cellRenderer: RowGroupCell,50 width: 200,51 pin: "start",52};53
54const aggSum: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {55 const values = data.map((x) => computeField<number>(field, x));56 return sum(values);57};58const aggAvg: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {59 const values = data.map((x) => computeField<number>(field, x));60 return sum(values) / values.length;61};62
63export default function PivotDemo() {64 const ds = useClientDataSource<GridSpec>({65 data: salesData,66 pivotMode: true,67 pivotModel: {68 columns: [{ id: "ageGroup" }],69 rows: [{ id: "country" }],70 measures: [71 {72 dim: {73 ...columns.find((x) => x.id === "profit")!,74 id: "Profit (sum)",75 field: "profit",76 name: "Profit (sum)",77 width: 100,78 },79 fn: "sum",80 },81 {82 dim: {83 ...columns.find((x) => x.id === "profit")!,84 id: "Profit (avg)",85 field: "profit",86 name: "Profit (avg)",87 width: 100,88 },89 fn: "avg",90 },91 ],92 },93 aggregateFns: { sum: aggSum, avg: aggAvg },94 });95
96 const pivotProps = ds.usePivotProps();97 return (98 <>99 <div className="ln-grid" style={{ height: 500 }}>100 <Grid101 columns={columns}102 rowSource={ds}103 columnBase={base}104 rowGroupColumn={group}105 {...pivotProps}106 styles={style}107 columnGroupRenderer={StickGroupHeader}108 />109 </div>110 </>111 );112}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 === "Male")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 <svg width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">61 <path d="M216,28H168a12,12,0,0,0,0,24h19L154.28,84.74a84,84,0,1,0,17,17L204,69V88a12,12,0,0,0,24,0V40A12,12,0,0,0,216,28ZM146.41,194.46a60,60,0,1,1,0-84.87A60.1,60.1,0,0,1,146.41,194.46Z"></path>62 </svg>63 </div>64 Male65 </div>66 );67
68 if (field === "Female")69 return (70 <div className="flex h-full w-full items-center gap-2">71 <div className="flex size-6 items-center justify-center rounded-full bg-pink-500/50">72 <svg width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">73 <path d="M212,96a84,84,0,1,0-96,83.13V196H88a12,12,0,0,0,0,24h28v20a12,12,0,0,0,24,0V220h28a12,12,0,0,0,0-24H140V179.13A84.12,84.12,0,0,0,212,96ZM68,96a60,60,0,1,1,60,60A60.07,60.07,0,0,1,68,96Z"></path>74 </svg>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: 0,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}LyteNyte Grid identifies measures by id. When you apply multiple measures to the same column,
you must provide a unique ID for each measure. The example below shows how to configure this:
1const ds = useClientDataSource<GridSpec>({2 pivotModel: {3 columns: [{ id: "ageGroup" }],4 rows: [{ id: "country" }],5 measures: [6 {7 dim: {8 ...columns.find((x) => x.id === "profit")!,9 id: "Profit (sum)",10 field: "profit",11 name: "Profit (sum)",12 width: 100,13 },14 fn: "sum",15 },16 {17 dim: {18 ...columns.find((x) => x.id === "profit")!,19 id: "Profit (avg)",20 field: "profit",21 name: "Profit (avg)",22 width: 100,23 },24 fn: "avg",25 },26 ],27 },28 aggregateFns: { sum: aggSum, avg: aggAvg },29});Next Steps
- Row & Column Pivots: Configure row and column dimensions for pivot view.
- Grand Totals: Display a grand total across all pivot measures.
- Pivot Sorting: Customize the sort order of pivoted row groups.
