crosstab — u_pbt_crosstab #
← Component reference · Guide contents
A complete crosstab table: row / column / value areas fed from a DataStore, aggregates, filters, conditional formatting, date grouping, calculated measures, and CSV / Excel exports.
▶ See it live — Demo application, Crosstab tile: the preview, the code behind it and this page, side by side.
At a glance #
| Userobject | u_pbt_crosstab |
| Item class | n_pbt_crosstab_field (a field, through of_field) · n_pbt_crosstab_value (one value of the Values area, through of_value) |
| Used for | Giving your users a cross-tab analysis of your data that they rearrange themselves, without writing SQL or exporting to Excel |
| Demo mode limit | 500 source rows processed — of_get_cell_value and of_get_cell_rows read those 500 rows, the same figures as on screen; CSV and Excel exports and copying (Ctrl+C) disabled — see demo mode |
How it works #
You hand the component a flat data set — a DataStore, so any query already written in your application. The crosstab takes care of the rest: it derives the field list, and you distribute those fields across four areas.
| Area | What it holds | Effect |
|---|---|---|
| Rows | Grouping fields | One row level per field, collapsible |
| Columns | Grouping fields | One column header level per field |
| Values | Numeric fields and their aggregate | What gets calculated in the cells |
| Filters | Selection fields | A filter above the table, applied to everything |
All the computing happens inside the component: once the data has been handed over, rearranging the table triggers no round trip to the database or to PowerBuilder.
Quick start #
// window open event
// Local variables
datastore lds
lds = create datastore
lds.dataobject = "d_sales"
lds.SetTransObject(SQLCA)
lds.Retrieve()
// 1. Hand over the data: fields are derived from the columns
uo_crosstab.of_from_datastore(/*ads*/ lds)
// 2. Distribute the fields across the areas
uo_crosstab.of_add_row_field(/*field*/ "region")
uo_crosstab.of_add_col_field(/*field*/ "year")
uo_crosstab.of_add_value_field(/*field*/ "amount", /*agg*/ uo_crosstab.AGG_SUM)
// 3. Present: amount format and grand totals
uo_crosstab.of_field(/*field*/ "amount").is_format = "$#,##0"
uo_crosstab.ib_row_grand_total = true
uo_crosstab.ib_col_grand_total = true
// ue_cell_double_clicked event of uo_crosstab: (string as_row_tuple_json, string as_col_tuple_json, integer ai_measure, double ad_value)
// The user wants the detail behind a figure: open the matching list.
of_open_detail(as_row_tuple_json, as_col_tuple_json)
Constants #
| Constant | Value | For |
|---|---|---|
TOTALS_BOTTOM · TOTALS_TOP | "bottom" "top" | is_totals_position |
VALUES_COLS · VALUES_ROWS | "cols" "rows" | is_values_axis |
THOUSANDS_LOCALE · THOUSANDS_SPACE · THOUSANDS_NONE | "locale" "space" "none" | is_thousands |
AGG_SUM · AGG_COUNT · AGG_COUNT_ROWS · AGG_DISTINCT_COUNT | "sum" "count" "countrows" "dcount" | of_add_value_field · is_agg |
AGG_AVG · AGG_MIN · AGG_MAX | "avg" "min" "max" | of_add_value_field · is_agg |
VALUEFILTER_TOP · VALUEFILTER_BOTTOM | "top" "bottom" | of_set_value_filter |
VALUEFILTER_GT · VALUEFILTER_LT · VALUEFILTER_BETWEEN | "gt" "lt" "between" | of_set_value_filter |
LABEL_GT · LABEL_LT · LABEL_BETWEEN | "gt" "lt" "between" | of_set_label_filter |
LABEL_CONTAINS · LABEL_BEGINS · LABEL_ENDS | "contains" "begins" "ends" | of_set_label_filter |
LABEL_BEFORE · LABEL_AFTER | "before" "after" | of_set_label_filter |
DATE_YEAR · DATE_QUARTER · DATE_MONTH | "year" "quarter" "month" | of_group_date_field |
The aggregate, filter, date and separator constants are read on the component: uo_crosstab.AGG_SUM. Those of a field or a value (SHOW_*, CF_*) are read on the class of its handle: n_pbt_crosstab_field.CF_SCALE, n_pbt_crosstab_value.SHOW_PCT_GRAND. AGG_COUNT counts the non-empty values, like Excel's Count; AGG_COUNT_ROWS counts the rows, empty ones included.
Properties #
| Property | Type | Default | Purpose |
|---|---|---|---|
is_totals_position | string | "bottom" | Where the grand total row sits: TOTALS_BOTTOM (at the foot, the default) or TOTALS_TOP (at the top, right below the headers). Only the grand total moves: the subtotal of a group stays on the group's own line |
is_values_axis | string | "cols" | How measures are laid out when there is more than one: VALUES_COLS (side by side in columns, the default) or VALUES_ROWS (stacked in rows) |
is_currency_symbol | string | "" | The currency the Number format menu of a value chip offers, next to No symbol and %. Empty = the display language's ($ in English, € elsewhere) |
is_thousands | string | "locale" | Thousands separator of the values that have no format of their own (is_format empty and no Format on the DataWindow column) — the same setting as the Options menu of the grid: THOUSANDS_LOCALE (the display language's, the default), THOUSANDS_SPACE, THOUSANDS_NONE, or the separator itself (",", ".", " ") |
ib_field_list | boolean | true | Shows the field panel, where the user rearranges the table with the mouse |
ib_row_subtotals | boolean | true | Shows the subtotal of each row group, written on the line of the group itself, above its members; off, that line keeps its label and shows no figure |
ib_col_subtotals | boolean | true | Shows a subtotal for each column group |
ib_row_grand_total | boolean | true | Shows the grand-total row under the table (the twin of ib_col_grand_total) |
ib_col_grand_total | boolean | true | Shows the grand-total column after the table (the twin of ib_row_grand_total). With no field in Columns there is no total column — the only column of values IS the total — and the property keeps its value for when a field enters. With several measures, the Total column has a "Total" header then one header per measure, sortable |
ib_copy_headers | boolean | true | Ctrl+C copies the headers with the selected cells: the column names on a first line (the measure is named there only when the table shows it: several values, or no field in Columns) and the member of each row — its whole path, "Nord / Lille" — in a first column, whose corner carries the names of the row fields ("region / ville"). A paste in a spreadsheet thus says what the figures are. false copies the figures alone. Read live, set back to true by of_reset |
ib_enabled | boolean | true | Greyed out: the grid still shows its figures — an empty crosstab is a different thing from one the application has switched off — but it stops answering the pointer |
is_theme_style | string | "" | Visual style of the component (THEME_STYLE_* constants); empty = the application's, followed at every change |
is_theme_mode | string | "" | Light or dark variant (THEME_MODE_* constants); empty = the application's, followed at every change |
il_theme_accent | long | -1 | Accent colour of this component (-1 = the application accent, or the theme's) |
is_tooltip | string | "" | Simple tooltip shown when hovering the component |
is_super_tooltip_title | string | "" | Title of the rich tooltip (takes precedence over is_tooltip) |
is_super_tooltip_text | string | "" | Text of the rich tooltip (rich markup accepted) |
is_super_tooltip_image | string | "" | Image of the rich tooltip |
Field properties #
of_field (string as_field) returns the handle of a field: you fetch it once, then you drive the field through its properties. The handle is created on the first call and reused afterwards.
| Property | Type | Default | Purpose |
|---|---|---|---|
is_label | string | DataWindow header | Readable caption for the field ("amount" → "Revenue"). By default the header text of the column in the DataWindow, else its name; an empty string gives it back. Set by your code, it survives of_from_datastore |
is_show | string | "normal" | What the cell displays: "normal", "pctGrand" (% of the grand total), "pctRow" (% of the row), "pctCol" (% of the column), "running" (running total), "diff" (difference from the previous one) |
is_conditional_formatting | string | "none" | Conditional formatting: CF_NONE, CF_SCALE (color scale), CF_BARS (bars inside the cell) or CF_ICONS (an arrow by third: up, level, down) |
is_format | string | column Format | Display format of the values of the field, a PowerBuilder display format ("#,##0.00", "$#,##0;($#,##0)", "0.0%"), written with the separators of the display language. Empty = the Format of the DataWindow column, read by of_from_datastore; with neither, is_thousands and at most two decimals. Read back: the format in force, the one set else the column's |
The values of is_show and is_conditional_formatting are also available as constants on the handle (SHOW_PCT_COL, CF_SCALE…). Set on the field, is_show, is_conditional_formatting and is_format reach every value of the field in the Values area and the ones that enter it later — set before the field is in Values, they are kept, read back as set and applied when it enters. For one value alone (the same field twice: the amount, and its share of the total), use the value handle, below.
// Local variables
n_pbt_crosstab_field lnv_field
// The amount field : its caption and a colour scale
lnv_field = uo_crosstab.of_field(/*field*/ "amount")
lnv_field.is_label = "Revenue"
lnv_field.is_conditional_formatting = lnv_field.CF_SCALE
⚠️ Breaking change. This property used to be called
is_cf: the abbreviation said nothing at the call site. The old name no longer exists — code that uses it does not compile. The replacement is mechanical:is_cf→is_conditional_formatting, with no change in values or behaviour.
Value properties #
of_value (integer ai_position) returns the handle of one value of the Values area, by its position (1 = the first). A field can sit there twice — the amount, and its share of the grand total — and each value has its own settings, which take precedence over those of its field. The handle designates a position: after of_remove_value, of_move_value, of_remove_field, of_remove_calc_measure, of_clear_layout or of_set_layout it is released — ask of_value again. Its SHOW_* and CF_* constants are those of n_pbt_crosstab_field.
| Property | Type | Default | Purpose |
|---|---|---|---|
is_agg | string | the one of of_add_value_field | Aggregate of this value: an AGG_* constant of the component (uo_crosstab.AGG_AVG). A calculated measure has none to choose: the assignment is ignored |
is_show | string | "normal" | What the cells of this value show: SHOW_NORMAL, SHOW_PCT_GRAND, SHOW_PCT_ROW, SHOW_PCT_COL, SHOW_RUNNING, SHOW_DIFF (as on the field) |
is_conditional_formatting | string | "none" | Conditional formatting of this value: CF_NONE, CF_SCALE, CF_BARS, CF_ICONS |
is_format | string | column Format | Display format of this value alone, a PowerBuilder display format ("#,##0.00", "$#,##0;($#,##0)"). Empty = the Format of the DataWindow column. Read back: the format in force |
ib_negative_red | boolean | false | The negative numbers of this value in red — on screen and in the Excel export. The same setting as the "Negatives in red" entry of the chip menu |
is_label | string | "" | Caption of this value in the headers ("Share of sales"); empty gives back the original one, the aggregate and the field caption ("Sum of Amount") |
| Method | Purpose |
|---|---|
of_field ( ) → string | The field this value aggregates ("amount"); "" when the position holds no value |
// Local variables
n_pbt_crosstab_value lnv_share, lnv_amount
// The amount twice in Values : the sum, then its share of the grand total
uo_crosstab.of_add_value_field(/*field*/ "amount", /*agg*/ uo_crosstab.AGG_SUM)
uo_crosstab.of_add_value_field(/*field*/ "amount", /*agg*/ uo_crosstab.AGG_SUM)
// The second value only : its share of the grand total, under its own caption
lnv_share = uo_crosstab.of_value(/*position*/ 2)
lnv_share.is_show = n_pbt_crosstab_value.SHOW_PCT_GRAND
lnv_share.is_label = "Share of sales"
// The first value : two decimals, negative amounts in red
lnv_amount = uo_crosstab.of_value(/*position*/ 1)
lnv_amount.is_format = "#,##0.00"
lnv_amount.ib_negative_red = true
Methods #
Feeding and naming #
| Method | Purpose |
|---|---|
of_from_datastore (datastore ads) | Hands over the data set: fields are derived from the DataStore columns, their captions from the header text, the default format of their values from the Format of the column. A text that holds a tab or a line break stays in its row; a CHAR value loses its padding blanks; a decimal is read with the separator of the workstation; an empty value (NULL, empty text) is one (blank) member, sorted last; the captions set by is_label survive a reload. Returns 0 once the data is loaded, -5 if the DataStore is not valid or has no column, -2 if the component is not created |
of_from_datastore (datawindow adw) | The same load from a DataWindow: the cross table reads its rows through a working DataStore that shares them (ShareData, checked) — what the DataWindow shows, the table crosses. Returns 0, -5 when adw is not valid, has no column or cannot share its rows (of_get_last_error says which), -2 when the component is not created |
of_from_datastore (datawindowchild adwc) | The same load from a DataWindowChild (a drop-down DataWindow). Same returns |
of_field (string as_field) | Returns the handle of a field, to caption or format it (see Field properties) |
of_value (integer ai_position) → n_pbt_crosstab_value | Returns the handle of one value of the Values area, by its position (1 = the first), to set its aggregate, display, format or caption (see Value properties) |
of_value_count ( ) → long | How many values the Values area holds (a field there twice counts twice); 0 when the area is empty or the component is not created |
Building the table #
| Method | Purpose |
|---|---|
of_clear_layout ( ) | Empties all four areas: the table goes back to blank, the data stays loaded. Returns 0 once applied, -2 if the component is not created |
of_add_row_field (string as_field) | Adds a field to the Rows area (the call order sets the level order). A field already in Values stays there: it can be in both (a distinct count per member). Returns 0 once applied, -5 for a field the data does not hold or for a calculated measure (it goes in Values only), -2 if the component is not created |
of_add_col_field (string as_field) | Adds a field to the Columns area. A field already in Values stays there: it can be in both (a distinct count per member). Returns 0 once applied, -5 for a field the data does not hold or for a calculated measure (it goes in Values only), -2 if the component is not created |
of_add_value_field (string as_field, string as_agg) | Adds a measure to the Values area, along with its aggregate (AGG_*, empty = AGG_SUM). The field keeps its place in Rows, Columns or Filters. Returns 0 once applied, -5 for a field the data does not hold or another aggregate, -2 if the component is not created |
of_add_filter_field (string as_field) | Adds a field to the Filters area, above the table. Returns 0 once applied, -5 for a field the data does not hold or for a calculated measure (it goes in Values only), -2 if the component is not created |
of_remove_field (string as_field) | Takes a field out of the table: out of every zone it sits in (Rows, Columns, Filters, and each of its values), with its filters — like "Remove" from the field list. The rest of the layout stays as it is, and ue_layout_changed tells the new one. Returns 0 once applied, -4 when the field sits in no zone, -5 for a field the data does not hold |
of_remove_value (integer ai_position) | Removes one value of the Values area, by its position (the 2nd "Sum of Amount" while the 1st stays); with the last value of a field placed nowhere else go its filters. Returns 0 once applied, -5 for a position that holds no value, -2 if the component is not created |
of_move_value (integer ai_from, integer ai_to) | Moves a value of the Values area from one position to another (1 = the first): its settings, the filters and the sort that name it go with it. Returns 0 once applied, -5 for a position that holds no value, -2 if the component is not created |
The aggregates accepted by of_add_value_field are carried by the component as constants: AGG_SUM (default), AGG_COUNT (the number of non-empty values, like Excel's Count), AGG_COUNT_ROWS (the number of rows, empty ones included), AGG_DISTINCT_COUNT (distinct value count, empty cells ignored as in Excel), AGG_AVG, AGG_MIN, AGG_MAX. A sum with no value at all is an empty cell, not 0; a "difference from the previous one" where either cell is empty is empty too. Percentages are written the way the display language writes them (57.9% in English, 57,9 % in French).
Totals and subtotals #
Totals are set through properties, described above: ib_row_grand_total and ib_col_grand_total for the grand totals, ib_row_subtotals and ib_col_subtotals for the subtotals, is_totals_position for where the grand-total row sits. The Options menu of the grid (the gear) offers the same four switches, one entry per axis: Grand total row, Grand total column, Row subtotals, Column subtotals.
Filtering #
| Method | Purpose |
|---|---|
of_set_member_filter (string as_field, string as_values_tab) | Keeps only the listed values of a field. Values are separated by tabs (~t) and written the way the grid keeps them (see under the table); an empty list removes the filter, like of_set_member_order. A field placed in no zone joins the Filters area, so that the filter shows. Returns 0 once applied, -5 for a field the data does not hold, -2 if the component is not created |
of_clear_member_filter (string as_field) | Removes that filter. Returns 0 once applied, -5 for a field the data does not hold, -2 if the component is not created |
of_set_value_filter (string as_field, string as_type, double ad_a, double ad_b, integer ai_measure_index) | Filters on the total of measure number ai_measure_index (the first one = 1): VALUEFILTER_TOP or VALUEFILTER_BOTTOM (the first or last ad_a), VALUEFILTER_GT, VALUEFILTER_LT (above, under ad_a), VALUEFILTER_BETWEEN (between ad_a and ad_b). On a field placed under another one of its zone, the ranking is made inside each parent (the best city of each region); at the first level, over the whole table. The members tied with the N-th one all stay. The field must sit in Rows or Columns. Returns 0 once applied, -5 for a field the data does not hold, another type, a Top or a Bottom of less than 1 member or a position with no measure in Values, -2 if the component is not created |
of_clear_value_filter (string as_field) | Removes that filter. Returns 0 once applied, -5 for a field the data does not hold, -2 if the component is not created |
of_set_label_filter (string as_field, string as_type, double ad_a, double ad_b) | Numeric filter on the value of the field itself: LABEL_GT, LABEL_LT, LABEL_BETWEEN. A date field takes days, through the text overload. The field must sit in Rows or Columns. Returns 0 once applied, -5 for a field the data does not hold, another type or a date field, -2 if the component is not created |
of_set_label_filter (string as_field, string as_type, string as_a, string as_b) | Text filter on the value of the field: LABEL_CONTAINS, LABEL_BEGINS, LABEL_ENDS (as_a, case ignored; as_b is unused). On a date field: LABEL_BEFORE, LABEL_AFTER (as_a, a day written yyyy-mm-dd) or LABEL_BETWEEN (as_a and as_b, both days included). Returns 0 once applied, -5 for a field the data does not hold, a type that does not fit the field (LABEL_CONTAINS on a date, LABEL_BEFORE on a text), an empty text or a day that is not one, -2 if the component is not created |
of_clear_label_filter (string as_field) | Removes that filter. Returns 0 once applied, -5 for a field the data does not hold, -2 if the component is not created |
The values of_set_member_filter and of_set_member_order expect are those the grid keeps, not always those it shows: on a field derived from a date, a month is written "01"…"12", a quarter "1"…"4", a year "2024"; a date member that is not grouped is written yyyy-mm-dd. The user, for his part, finds the label filter in the chip menu of every field — text, number or date.
Formatting #
The format of a value is set through a property: of_field("amount").is_format = "$#,##0.00" for every value of a field, of_value(2).is_format for one alone (see Field properties and Value properties). It is a PowerBuilder display format, read by the mask engine of the library; empty, the value takes the Format of the DataWindow column. Negatives in red: of_value(n).ib_negative_red = true. The thousands separator of the values with no format stays is_thousands, the currency of the chip menu is_currency_symbol.
| Method | Purpose |
|---|---|
of_set_member_order (string as_field, string as_values_tab) | Display order forced on the values of a field (separated by ~t); an empty string restores the natural order. Returns 0 once applied, -5 for a field the data does not hold, -2 if the component is not created |
Dates and calculated fields #
| Method | Purpose | |
|---|---|---|
of_group_date_field (string as_field, string as_part) | Creates a field derived from a date, datetime or timestamp column (a time alone has no date to group): DATE_YEAR, DATE_QUARTER or DATE_MONTH, named <column>__<part>. It joins the field list and is used like any other. Returns 0 once applied, -5 for a field the data does not hold, another part or a field that does not hold dates (a text, a time), -2 if the component is not created | |
of_add_calc_field (string as_name, string as_label, string as_formula) | Calculated field, evaluated row by row ("[amount] * 0.8" = the net of each sale, then summed like any column), usable in any area. Not for a ratio of totals (an average price): that is of_add_calc_measure. Returns 0 once created, -5 for an empty name or formula, a name holding / or ` | , a name already taken by a column or a formula that does not compile — the reason is then in of_get_last_error, and ue_calc_field_error is raised —, -2` if the component is not created |
of_remove_calc_field (string as_name) | Removes a calculated field. Returns 0 once applied, -5 when no calculated field has this name, -2 if the component is not created | |
of_add_calc_measure (string as_name, string as_label, string as_formula) | Calculated measure, evaluated cell by cell, on the totals ("[marge] / [ca]" = overall margin rate). Goes in the Values area only. Its formula names data fields only: another calculated measure is refused. Same codes as of_add_calc_field: 0 once created, -5 (the reason in of_get_last_error, ue_calc_field_error raised), -2 if the component is not created | |
of_remove_calc_measure (string as_name) | Removes a calculated measure. Returns 0 once applied, -5 when no calculated measure has this name, -2 if the component is not created |
A formula accepts the + - * / ( ) operators, numbers (12, 0.5, .5, 1e3, always with a point) and field names in square brackets; ^ is not an operator. An invalid formula raises ue_calc_field_error — nothing crashes.
Expanding, saving, exporting #
| Method | Purpose |
|---|---|
of_expand_all ( ) · of_collapse_all ( ) | Expands or collapses every group, rows AND columns. Returns 0 once applied, -2 if the component is not created |
of_expand_to_level (integer ai_level) | Expands down to a given level (1 = first level only). Returns 0 once applied, -5 below 1, -2 if the component is not created |
of_get_layout ( ) → string | Returns the full state of the table — store it as-is, then replay it through of_set_layout |
of_set_layout (string as_state_json) | Restores a state obtained earlier (the JSON of of_get_layout or of ue_layout_changed, as is). ue_layout_changed tells the new layout once, nothing when it is the one already shown; the value handles are released. Returns 0 once applied, -5 for an empty text or one that is not JSON, -2 if the component is not created |
of_get_cell_value (string as_row_tuple_json, string as_col_tuple_json, integer ai_measure, ref double ad_value) | Reads the value of one cell, the one the table shows (0.8 under 80.0%, the running total under a running total). Its row and its column are the tuples ue_cell_double_clicked gives — {"region":"Nord","city":"Lille"}, an empty member null, {} (or an empty string) for the total — and ai_measure is the position of the value in the Values area, from 1. A tuple names the fields of its zone from the first one down: {"region":"Nord"} is the subtotal of Nord. The answer does not depend on the display: a collapsed branch or hidden subtotals are read all the same. Returns 0 with the value in ad_value (NULL for a cell with no value), -4 when the table has no such cell (a member, or a field, it does not show there) or cannot be read now — called from an event the grid raised while an order of this script was waiting: of_get_last_error says so, read after the event —, -5 for a malformed tuple or a position with no measure, -2 if the component is not created |
of_get_cell_rows (string as_row_tuple_json, string as_col_tuple_json, ref long al_rows[]) → long | The rows of the DataStore a cell aggregates (drill-through): al_rows[] receives their row numbers and the count is returned. The cell is addressed like of_get_cell_value, without a measure — the same rows feed every value of the cell. The numbers are those the rows have now in the DataStore given to of_from_datastore, even after a sort or a filter of that DataStore; only the rows that pass every filter of the table count; in demo mode, the rows past the cap are not used, so not given; a row deleted since the load is left out. Reload the grid after a Retrieve. Returns the count (0 or more), -4 when the table has no such cell or cannot be read now (see of_get_cell_value), -5 for a malformed tuple, -2 if the component is not created |
of_export_csv (string as_path) | Writes a CSV file of the current view (UTF-8 with a BOM, semicolon separated); ue_csv_saved confirms. The numbers take the decimal separator of the display language (a comma in French, German, Italian, Spanish, Portuguese); a label that would read as a formula (= + - @) is written with a leading apostrophe. The twin of of_export_xlsx. Returns 0 once applied, -5 on an invalid argument, -2 if the component is not created |
of_export_xlsx (string as_path) | Writes an Excel file of the table as it is shown, formatting included (negatives in red too); ue_xlsx_saved confirms — or refuses with its reason past 16,384 columns or 1,048,576 rows, the limits of an Excel sheet. Returns 0 once applied, -5 on an invalid argument, -2 if the component is not created |
Shared #
| Method | Purpose |
|---|---|
of_reset ( ) | Returns the component to its brand-new state. Returns 0 once applied, -2 if the component is not created |
of_set_redraw (boolean) | Batches a burst of changes into a single render. Returns 0 once applied, -2 if the component is not created |
of_save_as_png (string) · of_save_as_jpg (string) | Exports the rendering as an image. Returns 0, -2 if the component is not created, -4 if the capture fails, -5 on an empty path |
Events #
| Event | Raised when |
|---|---|
ue_layout_changed (string as_layout_json) | The layout has changed — by the user (moved a field, changed an aggregate, sorted a column, collapsed a group…) or by the code: every order that changes what of_get_layout returns (of_set_layout included) raises it once, and only once at the end of a build framed by of_set_redraw(false) … of_set_redraw(true); never when nothing changes, nor for of_reset. as_layout_json is everything of_get_layout saves |
ue_cell_double_clicked (string as_row_tuple_json, string as_col_tuple_json, integer ai_measure, double ad_value) | Double-click on a cell: the first two arguments describe the intersection ({"region":"Nord","city":"Lille"}, an empty member is null there, {} for the total), ai_measure the position of the double-clicked value in the Values area, from 1, and ad_value the value the cell shows — 0.8 under "80.0%", the running total under a running total — or NULL for an empty cell. The two tuples and ai_measure make the address of_get_cell_value reads back. This is the entry point for a drill-down |
ue_csv_saved (string as_path, boolean ab_ok, string as_error) | The CSV file has been written — or not, and as_error says why |
ue_xlsx_saved (string as_path, boolean ab_ok, string as_error) | The Excel file has been written — or not, and as_error says why |
ue_calc_field_error (string as_field, string as_message) | A calculated field or measure formula is invalid |
ue_copy (string as_tsv) | The user has copied a cell selection (Ctrl+C): the grid has already put the text on the clipboard, the event hands you the same text — to log it or reuse it elsewhere. as_tsv is a tab-separated text that carries the headers by default — the column names on a first line, the member of each row (its whole path, "Nord / Lille") in a first column — or the figures alone with ib_copy_headers = false. In demo mode copying is an export: refused, and the grid says so |
ue_ready ( ) | The component has finished loading; everything sent beforehand has been replayed |
ue_runtime_missing ( ) | The WebView2 runtime is missing: the component stays empty |
ue_bg_color (long al_color) | The component has computed its theme background color; the userobject has already adopted it (backcolor) |
What the user can do without a single line of code #
The table is alive: that is the whole point of the component. With the field panel shown (ib_field_list = true), the user can:
- drag a field from one area to another, and rearrange the cross-tab at will;
- change the aggregate of a measure (sum, average, count…);
- filter the values of a field through a check list;
- collapse or expand a row or column group;
- sort on a header;
- select then copy a block of cells, with its headers so a paste in a spreadsheet says what the figures are (
ib_copy_headers).
Each of these actions, copying aside (it raises ue_copy), comes back in ue_layout_changed: combined with of_get_layout / of_set_layout, this lets you offer "saved views" to your users.
From the keyboard. The table is built without a mouse — chips, menus, collapsing and sorting: Tab lands on a field, a collapse triangle or a sortable header, Enter or Space triggers it. On a field it opens its menu — the one carrying Add to rows / columns / values / filters and Remove: the whole build of the table goes through it. Shift+F10 or the Menu key open that menu too, and the focus follows the field just moved, even to another zone. Selecting cells and copying them (Ctrl+C) need the mouse today.
Examples #
A complete sales report #
// Rows: region, then city within each region
uo_crosstab.of_clear_layout()
uo_crosstab.of_add_row_field(/*field*/ "region")
uo_crosstab.of_add_row_field(/*field*/ "city")
// Columns: one per year
uo_crosstab.of_add_col_field(/*field*/ "year")
// Cells: the total amount
uo_crosstab.of_add_value_field(/*field*/ "amount", /*agg*/ uo_crosstab.AGG_SUM)
// Presentation: readable amounts, subtotals and grand totals
uo_crosstab.of_field(/*field*/ "amount").is_format = "$#,##0"
uo_crosstab.ib_row_subtotals = true
uo_crosstab.ib_row_grand_total = true
uo_crosstab.ib_col_grand_total = true
// Color the cells to spot the big amounts at a glance
uo_crosstab.of_field(/*field*/ "amount").is_conditional_formatting = n_pbt_crosstab_field.CF_SCALE
Readable captions #
Your columns are often named mt_ht or cd_reg. Rename them once and for all, right after of_from_datastore.
// The data first : the fields take the names of the columns
uo_crosstab.of_from_datastore(/*ads*/ lds)
// Then a readable caption for each field
uo_crosstab.of_field(/*field*/ "region").is_label = "Region"
uo_crosstab.of_field(/*field*/ "city").is_label = "City"
uo_crosstab.of_field(/*field*/ "category").is_label = "Category"
uo_crosstab.of_field(/*field*/ "year").is_label = "Year"
uo_crosstab.of_field(/*field*/ "amount").is_label = "Revenue"
uo_crosstab.of_field(/*field*/ "quantity").is_label = "Quantity"
Analyzing shares rather than amounts #
// Local variables
n_pbt_crosstab_field lnv_amount
// Start from empty areas, then place the fields
uo_crosstab.of_clear_layout()
uo_crosstab.of_add_row_field(/*field*/ "category")
uo_crosstab.of_add_col_field(/*field*/ "year")
uo_crosstab.of_add_value_field(/*field*/ "amount", /*agg*/ uo_crosstab.AGG_SUM)
// Keep only two categories on screen (values separated by a tab)
uo_crosstab.of_set_member_filter(/*field*/ "category", /*values_tab*/ "Informatique~tMobilier")
// The amount field, to set how it is shown
lnv_amount = uo_crosstab.of_field(/*field*/ "amount")
// Show the share of each cell in the total of its column
lnv_amount.is_show = lnv_amount.SHOW_PCT_COL
// A small bar in each cell to compare the shares at a glance
lnv_amount.is_conditional_formatting = lnv_amount.CF_BARS
A measure of your own: the average price #
A calculated measure is evaluated on the totals of each cell, not row by row: that is what makes a ratio correct.
// Start from empty areas, then place the fields
uo_crosstab.of_clear_layout()
uo_crosstab.of_add_row_field(/*field*/ "region")
// The two totals the calculation will be based on
uo_crosstab.of_add_value_field(/*field*/ "amount", /*agg*/ uo_crosstab.AGG_SUM)
uo_crosstab.of_add_value_field(/*field*/ "quantity", /*agg*/ uo_crosstab.AGG_SUM)
// Average price = total amount divided by total quantity
uo_crosstab.of_add_calc_measure(/*name*/ "avg_price", /*label*/ "Average price", /*formula*/ "[amount] / [quantity]")
uo_crosstab.of_field(/*field*/ "avg_price").is_format = "$#,##0.00"
// Then place it among the values like any field (the aggregate does not matter: a measure is computed)
uo_crosstab.of_add_value_field(/*field*/ "avg_price", /*agg*/ uo_crosstab.AGG_SUM)
// ue_calc_field_error event of uo_crosstab: (string as_field, string as_message)
// Invalid formula: warn without breaking anything, the table stays on screen.
uo_status.of_panel(/*key*/ "main").is_text = "Formula " + as_field + ": " + as_message
Analyzing by month, quarter or year #
A date column cannot be cross-tabbed as is — every single day would get its own row. Derive the level you want first.
// Create three fields derived from the sale_date column
uo_crosstab.of_group_date_field(/*field*/ "sale_date", /*part*/ uo_crosstab.DATE_YEAR)
uo_crosstab.of_group_date_field(/*field*/ "sale_date", /*part*/ uo_crosstab.DATE_QUARTER)
uo_crosstab.of_group_date_field(/*field*/ "sale_date", /*part*/ uo_crosstab.DATE_MONTH)
// Then cross-tab them like any other field: year in columns, quarter underneath
uo_crosstab.of_add_col_field(/*field*/ "sale_date__year")
uo_crosstab.of_add_col_field(/*field*/ "sale_date__quarter")
The top ten regions #
// Keep only the 10 regions with the highest total on the first measure
uo_crosstab.of_set_value_filter(/*field*/ "region", /*type*/ uo_crosstab.VALUEFILTER_TOP, /*a*/ 10, /*b*/ 0, /*measure_index*/ 1)
On a field placed under another one, the ranking is made inside each parent: the best city of each region, not the best city of the country. Ties all stay.
// The cities under the regions
uo_crosstab.of_add_row_field(/*field*/ "region")
uo_crosstab.of_add_row_field(/*field*/ "city")
// The best city of each region, on the first measure
uo_crosstab.of_set_value_filter(/*field*/ "city", /*type*/ uo_crosstab.VALUEFILTER_TOP, /*a*/ 1, /*b*/ 0, /*measure_index*/ 1)
Exporting #
The export reproduces the current view exactly: same filters, same totals, same formatting.
// To Excel: the file is written straight to the given path
uo_crosstab.of_export_xlsx(/*path*/ "C:\temp\ventes.xlsx")
// ue_xlsx_saved event of uo_crosstab: (string as_path, boolean ab_ok, string as_error)
// inv_notif = an n_pbt_toaster declared as an instance variable of the window
if ab_ok then
inv_notif.is_title = "Export complete"
inv_notif.is_text = as_path
inv_notif.is_kind = inv_notif.KIND_SUCCESS
else
inv_notif.is_title = "Export failed"
inv_notif.is_text = as_error
inv_notif.is_kind = inv_notif.KIND_ERROR
end if
inv_notif.of_show()
// To CSV: a file, like for Excel; ue_csv_saved confirms
uo_crosstab.of_export_csv(/*path*/ "C:\exports\ventes.csv")
Offering saved views #
// Local variables
string ls_view
// Save the current view : of_get_layout answers straight away
ls_view = uo_crosstab.of_get_layout()
// Keep the content AS IS: it replays without any transformation.
of_save_view(is_current_view, ls_view)
// Later on: replay a saved view
uo_crosstab.of_set_layout(/*state_json*/ of_read_view("Ventes par region"))
Drilling down behind a figure #
// ue_cell_double_clicked event of uo_crosstab: (string as_row_tuple_json, string as_col_tuple_json, integer ai_measure, double ad_value)
// Local variables
long ll_rows[], ll_count, ll_i
// The rows of the DataStore behind the double-clicked figure
ll_count = uo_crosstab.of_get_cell_rows(/*row_tuple_json*/ as_row_tuple_json, /*col_tuple_json*/ as_col_tuple_json, /*rows*/ ll_rows)
// Nothing to show : no row, or a cell that cannot be read now
if ll_count <= 0 then return
// Copy them into the detail list : ids_sales is the DataStore given to of_from_datastore
dw_detail.Reset()
for ll_i = 1 to ll_count
ids_sales.RowsCopy(ll_rows[ll_i], ll_rows[ll_i], Primary!, dw_detail, dw_detail.RowCount() + 1, Primary!)
next
The two tuples and ai_measure make the address of the cell: of_get_cell_value reads it back later, as the table shows it at that moment.
// ue_cell_double_clicked event of uo_crosstab : the address of the cell, read again later
// Local variables
double ld_value
// Read the cell again from its address : 0 when it exists
if uo_crosstab.of_get_cell_value(/*row_tuple_json*/ as_row_tuple_json, /*col_tuple_json*/ as_col_tuple_json, /*measure*/ ai_measure, /*value*/ ld_value) = 0 then
// ld_value is what the cell shows ; NULL for an empty cell
end if
A cell can also be read without a click, through its members: a partial tuple names a subtotal. And of_remove_field takes a field out of all its zones at once, filters included.
// The sales of Nord in 2025, first measure of Values
// Local variables
double ld_north
// A partial address : the region and the year, no other member
uo_crosstab.of_get_cell_value(/*row_tuple_json*/ '{"region":"Nord"}', /*col_tuple_json*/ '{"year":"2025"}', /*measure*/ 1, /*value*/ ld_north)
// Take the year out of the table, filters included
uo_crosstab.of_remove_field(/*field*/ "year")
Best practices #
- Call
of_from_datastoreonly once per data set: rearranging the table afterwards costs nothing, handing the data over again is expensive. - Set the captions (
of_field("...").is_label) right afterof_from_datastore: they follow the field everywhere, including in the field panel and in the exports. - Filter on the SQL side whatever is not meant to be analyzed: the crosstab is fast, but a DataStore half the size opens twice as fast.
of_clear_layout()empties the areas without handing the data over again: that is the right call for offering several analyses on the same source.- A date field is always cross-tabbed through
of_group_date_field, never directly. - Leave the field panel visible on analysis screens, hide it on fixed dashboards.
Inherited from the common base #
These members exist on every visual component — they are not specific to this one. They are detailed once, in the transverse chapters; this table only says where to read them.
| Members | Role | Detailed in |
|---|---|---|
of_count · of_keys_at · of_has | Walk what the component holds | 3.2 Items |
of_reset | Put the component back to zero | 3.6 Resetting a component: of_reset() |
of_set_property · of_get_property · of_component_name | Driving a property by its name | 3.1 The property engine |
is_accessible_name · is_accessible_description | The name and description a screen reader says | 3.4 Optional properties and events |
of_register_shortcut · of_clear_shortcuts | The component's keyboard chords | 3.5 Keyboard shortcuts |
of_is_created · of_is_ready · of_get_last_error | Whether it was born, whether it is ready, what failed | 3.7 Diagnostics |
of_save_as_png · of_save_as_jpg | Export the rendering as an image | 3.8 Exporting the rendering as an image |
of_set_redraw | Group changes into a single repaint | 3.10 Best practices |
of_preload_icons | Icons shown with no delay | Instant display: of_icon |
of_set_translation | Translate one of the component's labels | 5.2 Adapting a label: of_set_translation |
of_focus_webview | Give the component the focus | 6.4 Keyboard and focus |
of_print · of_print_to_pdf | Print, or write a PDF | 6.9 Printing |
Two helpers are not inherited: of_icon and of_escape_markup live on n_pbt_utils. Declare one — n_pbt_utils lnv_utils, nothing to create — and call them on it.