@gooddata/sdk-ui-vis-commons 11.39.0-alpha.0 → 11.39.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ import { type IResolvedReferenceValues } from "./types.js";
2
+ /**
3
+ * Merge order matters: in-chart values override external ones, because the
4
+ * in-chart value is what the user already sees on the rendered point/feature.
5
+ * If a tooltip references the same id from both, showing the external (fetched)
6
+ * value would let it drift from what the chart pixel displays.
7
+ *
8
+ * @internal
9
+ */
10
+ export declare function composeCustomTooltipSectionHtml(content: string, inChartValues: IResolvedReferenceValues, externalValues: IResolvedReferenceValues, fallbackText: string): string;
@@ -0,0 +1,16 @@
1
+ // (C) 2026 GoodData Corporation
2
+ import { markdownToHtml } from "./markdownToHtml.js";
3
+ import { resolveReferences } from "./referenceResolver.js";
4
+ /**
5
+ * Merge order matters: in-chart values override external ones, because the
6
+ * in-chart value is what the user already sees on the rendered point/feature.
7
+ * If a tooltip references the same id from both, showing the external (fetched)
8
+ * value would let it drift from what the chart pixel displays.
9
+ *
10
+ * @internal
11
+ */
12
+ export function composeCustomTooltipSectionHtml(content, inChartValues, externalValues, fallbackText) {
13
+ const merged = { ...externalValues, ...inChartValues };
14
+ const resolved = resolveReferences(content, merged, fallbackText);
15
+ return `<div class="gd-viz-tooltip-custom-section">${markdownToHtml(resolved)}</div>`;
16
+ }
@@ -9,7 +9,8 @@
9
9
  * - Unordered lists (- item or * item)
10
10
  * - Ordered lists (1. item)
11
11
  * - Images (![alt](url))
12
- * - Links ([text](url)) — rendered as plain styled text (not clickable in tooltips)
12
+ * - Links ([text](url)) — rendered as clickable anchors (target=_blank, rel=noopener noreferrer)
13
+ * for `http(s):` URLs; unsafe URLs (javascript:, data:text/...) fall back to plain text
13
14
  * - Horizontal rules (--- or ***)
14
15
  * - Line breaks
15
16
  * - Backslash escapes (\*, \_, \[, \!, etc.) — render the metachar as literal text.
@@ -25,9 +26,16 @@
25
26
  function escapeHtml(text) {
26
27
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
27
28
  }
28
- function isSafeUrl(url) {
29
+ // http(s) navigation is the only safe scheme for clickable links. Data URLs are
30
+ // fine inline for <img> sources, but allowing them on <a href> would let an
31
+ // `[link](data:image/svg+xml,...)` target an SVG that may execute scripts on
32
+ // navigation.
33
+ function isSafeImageUrl(url) {
29
34
  return /^https?:\/\//i.test(url) || /^data:image\//i.test(url);
30
35
  }
36
+ function isSafeLinkUrl(url) {
37
+ return /^https?:\/\//i.test(url);
38
+ }
31
39
  // URL pattern allowing one level of balanced parens, e.g.
32
40
  // https://en.wikipedia.org/wiki/Page_(name)
33
41
  const URL_PATTERN = "(?:[^()\\s]|\\([^)]*\\))+";
@@ -41,14 +49,19 @@ function processInlineMarkdown(text) {
41
49
  let result = escapeHtml(text);
42
50
  // Inline style as fallback since the tooltip renders outside the normal DOM tree.
43
51
  result = result.replace(IMAGE_REGEX, (_match, alt, url) => {
44
- if (!isSafeUrl(url)) {
52
+ if (!isSafeImageUrl(url)) {
45
53
  return `${alt}`;
46
54
  }
47
55
  return `<img src="${url}" alt="${alt}" style="max-width: 100%; display: block; margin: 4px 0;" />`;
48
56
  });
49
- // Render as styled text links are intentionally not clickable inside tooltips.
50
- result = result.replace(LINK_REGEX, (_match, linkText) => {
51
- return `<span class="gd-viz-tooltip-custom-link">${linkText}</span>`;
57
+ // Always emit a `target="_blank"` anchor for http(s) URLs; whether the user
58
+ // can practically reach it depends on the tooltip mode's lifecycle (the
59
+ // tooltip needs to stay open long enough to mouse over the link).
60
+ result = result.replace(LINK_REGEX, (_match, linkText, url) => {
61
+ if (!isSafeLinkUrl(url)) {
62
+ return linkText;
63
+ }
64
+ return `<a href="${url}" target="_blank" rel="noopener noreferrer">${linkText}</a>`;
52
65
  });
53
66
  // Bold-italic: ***text*** — must run before bold and italic so the triple
54
67
  // asterisks are consumed as a unit instead of being split across patterns.
@@ -138,6 +151,7 @@ function parseMarkdown(markdown) {
138
151
  const trimmed = line.trim();
139
152
  if (!trimmed) {
140
153
  closeList();
154
+ htmlParts.push("<br/>");
141
155
  continue;
142
156
  }
143
157
  // Horizontal rule: --- or *** or ___
@@ -0,0 +1,43 @@
1
+ import { type IExecutionFactory, type IPreparedExecution } from "@gooddata/sdk-backend-spi";
2
+ import { type IExecutionDefinition } from "@gooddata/sdk-model";
3
+ /**
4
+ * Maps used by `buildLookupTable` to interpret the execution result.
5
+ *
6
+ * @internal
7
+ */
8
+ export interface ITooltipExecutionMeta {
9
+ /** value localId → count localId, for "Multiple items" detection. */
10
+ labelCountMap: Record<string, string>;
11
+ /** tooltip metric localId → LDM measure identifier. */
12
+ measureIdMap: Record<string, string>;
13
+ /** label value localId → LDM label identifier. */
14
+ labelIdMap: Record<string, string>;
15
+ }
16
+ /**
17
+ * Prepared tooltip execution paired with the meta needed to interpret its result.
18
+ * Carry them together — meta from one call mis-interprets results from another.
19
+ *
20
+ * @internal
21
+ */
22
+ export interface ITooltipExecutionBundle {
23
+ execution: IPreparedExecution;
24
+ meta: ITooltipExecutionMeta;
25
+ }
26
+ /**
27
+ * @internal
28
+ */
29
+ export interface IBuildTooltipExecutionOptions {
30
+ /**
31
+ * LocalIdentifiers from `definition.attributes` to use as the row dimension,
32
+ * in the desired order. Omit to use all attributes in definition order
33
+ * (Highcharts default). Geo passes an explicit list to drop position attrs.
34
+ */
35
+ slicingAttributeLocalIds?: readonly string[];
36
+ }
37
+ /**
38
+ * Returns `null` when the content has no references or all references are
39
+ * already in the chart (resolvable from drill data without a secondary call).
40
+ *
41
+ * @internal
42
+ */
43
+ export declare function buildTooltipExecution(executionFactory: IExecutionFactory, chartDefinition: IExecutionDefinition, tooltipContent: string, options?: IBuildTooltipExecutionOptions): ITooltipExecutionBundle | null;
@@ -0,0 +1,152 @@
1
+ // (C) 2026 GoodData Corporation
2
+ import { MeasureGroupIdentifier, filterMeasureRef, idRef, isIdentifierRef, isLocalIdRef, isSimpleMeasure, measureItem, measureLocalId, newAttribute, newMeasure, newTwoDimensional, } from "@gooddata/sdk-model";
3
+ import { REFERENCE_REGEX_MATCH } from "@gooddata/sdk-ui-kit";
4
+ function parseReferences(content) {
5
+ const refs = [];
6
+ const seen = new Set();
7
+ // REFERENCE_REGEX_MATCH lives in sdk-ui-kit; match[3] is the kind
8
+ // ("label" | "metric"), match[4] is the id.
9
+ for (const match of content.matchAll(REFERENCE_REGEX_MATCH)) {
10
+ const rawType = match[3].toLowerCase();
11
+ // Synonyms like `measure` / `displayForm` aren't canonical here; let
12
+ // them fall through so the rest of the pipeline doesn't silently treat
13
+ // a `measure` ref as a metric.
14
+ if (rawType !== "metric" && rawType !== "label") {
15
+ continue;
16
+ }
17
+ const id = match[4];
18
+ const key = `${rawType}/${id}`;
19
+ if (!seen.has(key)) {
20
+ seen.add(key);
21
+ refs.push({ type: rawType, id });
22
+ }
23
+ }
24
+ return refs;
25
+ }
26
+ function getChartMetricIds(definition) {
27
+ const ids = new Set();
28
+ for (const measure of definition.measures) {
29
+ if (isSimpleMeasure(measure)) {
30
+ const ref = measureItem(measure);
31
+ if (ref && isIdentifierRef(ref)) {
32
+ ids.add(ref.identifier);
33
+ }
34
+ }
35
+ }
36
+ return ids;
37
+ }
38
+ function getChartLabelIds(definition) {
39
+ // Only display-form identifier refs match. Refs in user content using a
40
+ // URI ref or a parent attribute id miss the set, fall into the secondary
41
+ // execution, and fail backend-side (one bad ref drops the whole call).
42
+ const ids = new Set();
43
+ for (const attr of definition.attributes) {
44
+ const ref = attr.attribute.displayForm;
45
+ if (isIdentifierRef(ref)) {
46
+ ids.add(ref.identifier);
47
+ }
48
+ }
49
+ return ids;
50
+ }
51
+ /**
52
+ * Chart measures that MVF/ranking filters depend on. Must be included in the
53
+ * tooltip execution with their original localIds so the filter refs resolve and
54
+ * tooltip values stay filter-consistent with the chart.
55
+ */
56
+ function getFilterDependencyMeasures(filters, chartMeasures) {
57
+ const neededLocalIds = new Set();
58
+ for (const filter of filters) {
59
+ const measureRef = filterMeasureRef(filter);
60
+ if (measureRef && isLocalIdRef(measureRef)) {
61
+ neededLocalIds.add(measureRef.localIdentifier);
62
+ }
63
+ }
64
+ if (neededLocalIds.size === 0) {
65
+ return [];
66
+ }
67
+ return chartMeasures.filter((m) => neededLocalIds.has(measureLocalId(m)));
68
+ }
69
+ /**
70
+ * Build tooltip-only measures for refs not already in the chart.
71
+ * Labels get a max+count pair (mirrors the RichText widget pattern) so the
72
+ * lookup can render "(Multiple items)" when a label resolves to >1 value per row.
73
+ *
74
+ * LocalId prefixes `tt_m_`, `tt_lv_`, `tt_lc_` are reserved — collision with
75
+ * chart-side measure localIds would break filter-dependency reuse.
76
+ */
77
+ function buildTooltipItems(refs, chartMetricIds, chartLabelIds) {
78
+ const measures = [];
79
+ const labelCountMap = {};
80
+ const labelIdMap = {};
81
+ const measureIdMap = {};
82
+ let idx = 0;
83
+ for (const ref of refs) {
84
+ if (ref.type === "metric" && !chartMetricIds.has(ref.id)) {
85
+ const localId = `tt_m_${idx++}`;
86
+ measures.push(newMeasure(idRef(ref.id, "measure"), (m) => m.localId(localId)));
87
+ measureIdMap[localId] = ref.id;
88
+ }
89
+ else if (ref.type === "label" && !chartLabelIds.has(ref.id)) {
90
+ const valueLocalId = `tt_lv_${idx}`;
91
+ const countLocalId = `tt_lc_${idx}`;
92
+ idx++;
93
+ measures.push(newMeasure(idRef(ref.id, "displayForm"), (m) => m.localId(valueLocalId).aggregation("max")));
94
+ measures.push(newMeasure(idRef(ref.id, "displayForm"), (m) => m.localId(countLocalId).aggregation("count")));
95
+ labelCountMap[valueLocalId] = countLocalId;
96
+ labelIdMap[valueLocalId] = ref.id;
97
+ }
98
+ }
99
+ return { measures, labelCountMap, labelIdMap, measureIdMap };
100
+ }
101
+ /**
102
+ * Slicing attributes for the tooltip execution's row dimension. When
103
+ * `slicingAttributeLocalIds` is passed, attributes are filtered AND reordered
104
+ * to match the caller's list (geo layers exclude position attributes and rely
105
+ * on the order being theirs).
106
+ */
107
+ function getSlicingAttributes(definition, slicingAttributeLocalIds) {
108
+ if (!slicingAttributeLocalIds) {
109
+ return definition.attributes.map((attr) => newAttribute(attr.attribute.displayForm, (a) => a.localId(attr.attribute.localIdentifier)));
110
+ }
111
+ const byLocalId = new Map(definition.attributes.map((a) => [a.attribute.localIdentifier, a]));
112
+ const out = [];
113
+ for (const localId of slicingAttributeLocalIds) {
114
+ const attr = byLocalId.get(localId);
115
+ if (attr) {
116
+ out.push(newAttribute(attr.attribute.displayForm, (a) => a.localId(localId)));
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+ /**
122
+ * Returns `null` when the content has no references or all references are
123
+ * already in the chart (resolvable from drill data without a secondary call).
124
+ *
125
+ * @internal
126
+ */
127
+ export function buildTooltipExecution(executionFactory, chartDefinition, tooltipContent, options) {
128
+ const refs = parseReferences(tooltipContent);
129
+ if (refs.length === 0) {
130
+ return null;
131
+ }
132
+ const chartMetricIds = getChartMetricIds(chartDefinition);
133
+ const chartLabelIds = getChartLabelIds(chartDefinition);
134
+ const { measures, labelCountMap, labelIdMap, measureIdMap } = buildTooltipItems(refs, chartMetricIds, chartLabelIds);
135
+ if (measures.length === 0) {
136
+ return null;
137
+ }
138
+ const chartAttrs = getSlicingAttributes(chartDefinition, options?.slicingAttributeLocalIds);
139
+ const filterDepMeasures = getFilterDependencyMeasures(chartDefinition.filters, chartDefinition.measures);
140
+ const allItems = [...chartAttrs, ...measures, ...filterDepMeasures];
141
+ const attrLocalIds = chartAttrs.map((a) => a.attribute.localIdentifier);
142
+ let execution = executionFactory
143
+ .forItems(allItems, chartDefinition.filters)
144
+ .withDimensions(...newTwoDimensional(attrLocalIds, [MeasureGroupIdentifier]));
145
+ if (chartDefinition.executionConfig) {
146
+ execution = execution.withExecConfig(chartDefinition.executionConfig);
147
+ }
148
+ return {
149
+ execution,
150
+ meta: { labelCountMap, measureIdMap, labelIdMap },
151
+ };
152
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Lookup-key helpers shared by `buildLookupTable` and chart-family hover-time
3
+ * key builders. Both sides must produce identical strings.
4
+ *
5
+ * Format: `${displayFormId}:${uri}` per attribute, joined by `|` after
6
+ * lexicographic sort. The `displayFormId` is always the idRef identifier —
7
+ * uriRef-backed display forms are skipped at the tooltip-execution-planning
8
+ * step (in each chart family's adapter), so they never reach this key builder.
9
+ * Name is omitted: the backend substitutes null/empty names with localized
10
+ * strings only on the display side, so including them here causes
11
+ * lookup-vs-hover mismatches on null/empty rows.
12
+ */
13
+ /**
14
+ * @internal
15
+ */
16
+ export declare function buildKeySegment(displayFormId: string, uri: string): string;
17
+ /**
18
+ * @internal
19
+ */
20
+ export declare function joinKeySegments(parts: readonly string[]): string;
@@ -0,0 +1,25 @@
1
+ // (C) 2026 GoodData Corporation
2
+ /**
3
+ * Lookup-key helpers shared by `buildLookupTable` and chart-family hover-time
4
+ * key builders. Both sides must produce identical strings.
5
+ *
6
+ * Format: `${displayFormId}:${uri}` per attribute, joined by `|` after
7
+ * lexicographic sort. The `displayFormId` is always the idRef identifier —
8
+ * uriRef-backed display forms are skipped at the tooltip-execution-planning
9
+ * step (in each chart family's adapter), so they never reach this key builder.
10
+ * Name is omitted: the backend substitutes null/empty names with localized
11
+ * strings only on the display side, so including them here causes
12
+ * lookup-vs-hover mismatches on null/empty rows.
13
+ */
14
+ /**
15
+ * @internal
16
+ */
17
+ export function buildKeySegment(displayFormId, uri) {
18
+ return `${displayFormId}:${uri}`;
19
+ }
20
+ /**
21
+ * @internal
22
+ */
23
+ export function joinKeySegments(parts) {
24
+ return [...parts].sort().join("|");
25
+ }
@@ -0,0 +1,21 @@
1
+ import { type IDataView } from "@gooddata/sdk-backend-spi";
2
+ import { type ISeparators } from "@gooddata/sdk-model";
3
+ import { type ITooltipExecutionMeta } from "./tooltipExecution.js";
4
+ import { type IResolvedReferenceValues } from "./types.js";
5
+ /**
6
+ * Localized placeholders for unresolved reference values. Mirrors the RichText
7
+ * widget's `richText.no_data` / `richText.multiple_data` messages.
8
+ *
9
+ * @internal
10
+ */
11
+ export interface ITooltipLookupLocalizedStrings {
12
+ noData: string;
13
+ multipleItems: string;
14
+ }
15
+ /**
16
+ * Build a per-data-point lookup keyed by `${displayFormId}:${uri}` segments
17
+ * (joined by `|`, sorted). Iteration is orientation-agnostic via slices/series.
18
+ *
19
+ * @internal
20
+ */
21
+ export declare function buildLookupTable(dataView: IDataView, meta: ITooltipExecutionMeta, separators?: ISeparators, localizedStrings?: ITooltipLookupLocalizedStrings): Map<string, IResolvedReferenceValues>;
@@ -0,0 +1,83 @@
1
+ // (C) 2026 GoodData Corporation
2
+ import { ClientFormatterFacade } from "@gooddata/number-formatter";
3
+ import { isResultAttributeHeader } from "@gooddata/sdk-model";
4
+ import { DataViewFacade } from "@gooddata/sdk-ui";
5
+ import { buildKeySegment, joinKeySegments } from "./tooltipKey.js";
6
+ const DEFAULT_LOCALIZED_STRINGS = {
7
+ noData: "(No data)",
8
+ multipleItems: "(Multiple items)",
9
+ };
10
+ /**
11
+ * Build a per-data-point lookup keyed by `${displayFormId}:${uri}` segments
12
+ * (joined by `|`, sorted). Iteration is orientation-agnostic via slices/series.
13
+ *
14
+ * @internal
15
+ */
16
+ export function buildLookupTable(dataView, meta, separators, localizedStrings = DEFAULT_LOCALIZED_STRINGS) {
17
+ const lookup = new Map();
18
+ const facade = DataViewFacade.for(dataView);
19
+ const slices = facade.data().slices().toArray();
20
+ const seriesArray = facade.data().series().toArray();
21
+ const dimDescriptors = facade.meta().dimensionItemDescriptors(0);
22
+ const countLocalIds = new Set(Object.values(meta.labelCountMap));
23
+ const seriesByLocalId = new Map(seriesArray.map((s) => [s.descriptor.measureDescriptor.measureHeaderItem.localIdentifier, s]));
24
+ for (let sliceIdx = 0; sliceIdx < slices.length; sliceIdx++) {
25
+ const slice = slices[sliceIdx];
26
+ const sliceHeaders = slice.descriptor.headers;
27
+ const keyParts = [];
28
+ for (let i = 0; i < sliceHeaders.length; i++) {
29
+ const header = sliceHeaders[i];
30
+ const descriptor = dimDescriptors[i];
31
+ if (header && isResultAttributeHeader(header) && descriptor && "attributeHeader" in descriptor) {
32
+ const dfId = descriptor.attributeHeader.identifier;
33
+ const uri = header.attributeHeaderItem.uri ?? "";
34
+ keyParts.push(buildKeySegment(dfId, uri));
35
+ }
36
+ }
37
+ const pointKey = joinKeySegments(keyParts);
38
+ const values = {};
39
+ for (const series of seriesArray) {
40
+ const measureDesc = series.descriptor.measureDescriptor;
41
+ const localId = measureDesc.measureHeaderItem.localIdentifier;
42
+ const dataPoint = series.dataPoints()[sliceIdx];
43
+ const rawValue = dataPoint?.rawValue;
44
+ if (countLocalIds.has(localId)) {
45
+ continue;
46
+ }
47
+ const labelId = meta.labelIdMap[localId];
48
+ if (labelId) {
49
+ const countLocalId = meta.labelCountMap[localId];
50
+ const countSeries = countLocalId ? seriesByLocalId.get(countLocalId) : undefined;
51
+ const countValue = countSeries
52
+ ? Number(countSeries.dataPoints()[sliceIdx]?.rawValue ?? 0)
53
+ : 1;
54
+ if (countValue > 1) {
55
+ values[`label/${labelId}`] = localizedStrings.multipleItems;
56
+ }
57
+ else if (rawValue == null || rawValue === "") {
58
+ values[`label/${labelId}`] = localizedStrings.noData;
59
+ }
60
+ else {
61
+ values[`label/${labelId}`] = String(rawValue);
62
+ }
63
+ continue;
64
+ }
65
+ const metricId = meta.measureIdMap[localId];
66
+ if (metricId) {
67
+ const format = measureDesc.measureHeaderItem.format;
68
+ if (rawValue == null) {
69
+ values[`metric/${metricId}`] = localizedStrings.noData;
70
+ }
71
+ else if (format) {
72
+ const { formattedValue } = ClientFormatterFacade.formatValue(Number(rawValue), format, separators);
73
+ values[`metric/${metricId}`] = formattedValue;
74
+ }
75
+ else {
76
+ values[`metric/${metricId}`] = String(rawValue);
77
+ }
78
+ }
79
+ }
80
+ lookup.set(pointKey, values);
81
+ }
82
+ return lookup;
83
+ }
@@ -28,7 +28,9 @@ export interface ICustomTooltipConfig {
28
28
  * - Bold (`**text**`), italic (`*text*`)
29
29
  * - Unordered lists (`- item`) and ordered lists (`1. item`) — not nested
30
30
  * - Images (`![alt](url)`) — `https:`, `http:`, and `data:image/...` URLs only
31
- * - Links (`[text](url)`) — rendered as styled text, NOT clickable inside tooltips
31
+ * - Links (`[text](url)`) — `http(s)` URLs only; rendered as anchors opening in
32
+ * a new tab. End-users can only reach them when the tooltip stays open long
33
+ * enough to interact with (accessible/sticky Highcharts tooltips, geo popups).
32
34
  * - Horizontal rules (`---`)
33
35
  * - Backslash escapes (`\*`, `\_`, `\[`, `\!`, etc.) to render a metacharacter
34
36
  * as literal text instead of formatting
@@ -40,6 +42,12 @@ export interface ICustomTooltipConfig {
40
42
  * automatically backslash-escaped, so data containing markdown metacharacters
41
43
  * renders as literal text — no manual escaping is required.
42
44
  *
45
+ * Use display-form identifiers (NOT parent attribute identifiers) inside
46
+ * `{label/id}`. An attribute id renders correctly for attributes that are
47
+ * already in the chart, but it cannot be fetched as a label for external
48
+ * attributes — and a single such ref causes the secondary tooltip fetch
49
+ * to fail backend-side, dropping every other external ref alongside it.
50
+ *
43
51
  * @see https://www.gooddata.com/docs/cloud/create-visualizations/custom-tooltips/
44
52
  */
45
53
  content?: string;
@@ -0,0 +1,43 @@
1
+ import { type IPreparedExecution } from "@gooddata/sdk-backend-spi";
2
+ import { type ISeparators } from "@gooddata/sdk-model";
3
+ import { type ITooltipExecutionBundle, type ITooltipExecutionMeta } from "./tooltipExecution.js";
4
+ import { type ITooltipLookupLocalizedStrings } from "./tooltipLookup.js";
5
+ import { type IResolvedReferenceValues } from "./types.js";
6
+ /**
7
+ * One prepared tooltip execution paired with a caller-owned key and the
8
+ * context that travels with the built lookup.
9
+ *
10
+ * @internal
11
+ */
12
+ export interface ITooltipLookupExecutionEntry<TContext> {
13
+ key: string;
14
+ execution: IPreparedExecution;
15
+ meta: ITooltipExecutionMeta;
16
+ context: TContext;
17
+ }
18
+ /**
19
+ * Built lookup for one tooltip execution entry.
20
+ *
21
+ * @internal
22
+ */
23
+ export interface ITooltipLookupExecutionResult<TContext> {
24
+ lookup: Map<string, IResolvedReferenceValues>;
25
+ context: TContext;
26
+ }
27
+ /**
28
+ * Single-bundle variant for chart families that have one tooltip execution per
29
+ * chart (e.g. Highcharts). Returns `undefined` while no bundle is provided or
30
+ * before the first result lands; consumers handle that as "no external values".
31
+ *
32
+ * @internal
33
+ */
34
+ export declare function useTooltipLookup(bundle: ITooltipExecutionBundle | undefined, separators?: ISeparators, localizedStrings?: ITooltipLookupLocalizedStrings): Map<string, IResolvedReferenceValues> | undefined;
35
+ /**
36
+ * Multi-bundle variant for chart families that key tooltip executions per
37
+ * sub-target (e.g. geo per-layer). `context` is required so the produced
38
+ * lookup carries whatever the caller needs to interpret the result —
39
+ * downstream code does not have to defensively check for missing context.
40
+ *
41
+ * @internal
42
+ */
43
+ export declare function useTooltipLookupExecutions<TContext>(entries: readonly ITooltipLookupExecutionEntry<TContext>[], separators?: ISeparators, localizedStrings?: ITooltipLookupLocalizedStrings): Map<string, ITooltipLookupExecutionResult<TContext>>;
@@ -0,0 +1,63 @@
1
+ // (C) 2026 GoodData Corporation
2
+ import { useMemo } from "react";
3
+ import { useCancelablePromise } from "@gooddata/sdk-ui";
4
+ import { buildLookupTable } from "./tooltipLookup.js";
5
+ const EMPTY_LOOKUPS = new Map();
6
+ async function executeOne(execution) {
7
+ const result = await execution.execute();
8
+ return result.readAll();
9
+ }
10
+ function getEntriesFingerprint(entries) {
11
+ return entries.map((entry) => `${entry.key}::${entry.execution.fingerprint()}`).join("||");
12
+ }
13
+ async function executeAll(entries) {
14
+ const settled = await Promise.allSettled(entries.map(async (entry) => ({ ...entry, dataView: await executeOne(entry.execution) })));
15
+ // Failed entries drop out silently; callers fall back when a lookup is missing.
16
+ return settled.flatMap((result) => (result.status === "fulfilled" ? [result.value] : []));
17
+ }
18
+ /**
19
+ * Single-bundle variant for chart families that have one tooltip execution per
20
+ * chart (e.g. Highcharts). Returns `undefined` while no bundle is provided or
21
+ * before the first result lands; consumers handle that as "no external values".
22
+ *
23
+ * @internal
24
+ */
25
+ export function useTooltipLookup(bundle, separators, localizedStrings) {
26
+ const fingerprint = bundle?.execution.fingerprint();
27
+ const { result } = useCancelablePromise({
28
+ promise: bundle ? () => executeOne(bundle.execution) : undefined,
29
+ }, [fingerprint]);
30
+ return useMemo(() => {
31
+ if (!result || !bundle) {
32
+ return undefined;
33
+ }
34
+ return buildLookupTable(result, bundle.meta, separators, localizedStrings);
35
+ }, [result, bundle, separators, localizedStrings]);
36
+ }
37
+ /**
38
+ * Multi-bundle variant for chart families that key tooltip executions per
39
+ * sub-target (e.g. geo per-layer). `context` is required so the produced
40
+ * lookup carries whatever the caller needs to interpret the result —
41
+ * downstream code does not have to defensively check for missing context.
42
+ *
43
+ * @internal
44
+ */
45
+ export function useTooltipLookupExecutions(entries, separators, localizedStrings) {
46
+ const fingerprint = getEntriesFingerprint(entries);
47
+ const { result } = useCancelablePromise({
48
+ promise: entries.length > 0 ? () => executeAll(entries) : undefined,
49
+ }, [fingerprint]);
50
+ return useMemo(() => {
51
+ if (!result) {
52
+ return EMPTY_LOOKUPS;
53
+ }
54
+ const lookups = new Map();
55
+ for (const entry of result) {
56
+ lookups.set(entry.key, {
57
+ lookup: buildLookupTable(entry.dataView, entry.meta, separators, localizedStrings),
58
+ context: entry.context,
59
+ });
60
+ }
61
+ return lookups;
62
+ }, [result, separators, localizedStrings]);
63
+ }
package/esm/index.d.ts CHANGED
@@ -32,3 +32,8 @@ export { type ICustomTooltipConfig, type CustomTooltipPlacement, type IResolvedR
32
32
  export { markdownToHtml } from "./customTooltip/markdownToHtml.js";
33
33
  export { resolveReferences } from "./customTooltip/referenceResolver.js";
34
34
  export { resolveMeasureLdmIdentifier } from "./customTooltip/measureLdmIdentifier.js";
35
+ export { buildTooltipExecution, type IBuildTooltipExecutionOptions, type ITooltipExecutionBundle, type ITooltipExecutionMeta, } from "./customTooltip/tooltipExecution.js";
36
+ export { buildLookupTable, type ITooltipLookupLocalizedStrings } from "./customTooltip/tooltipLookup.js";
37
+ export { buildKeySegment, joinKeySegments } from "./customTooltip/tooltipKey.js";
38
+ export { composeCustomTooltipSectionHtml } from "./customTooltip/composeSectionHtml.js";
39
+ export { useTooltipLookup, useTooltipLookupExecutions, type ITooltipLookupExecutionEntry, type ITooltipLookupExecutionResult, } from "./customTooltip/useTooltipLookupExecutions.js";
package/esm/index.js CHANGED
@@ -32,3 +32,8 @@ export { PatternFill } from "./coloring/PatternFill.js";
32
32
  export { markdownToHtml } from "./customTooltip/markdownToHtml.js";
33
33
  export { resolveReferences } from "./customTooltip/referenceResolver.js";
34
34
  export { resolveMeasureLdmIdentifier } from "./customTooltip/measureLdmIdentifier.js";
35
+ export { buildTooltipExecution, } from "./customTooltip/tooltipExecution.js";
36
+ export { buildLookupTable } from "./customTooltip/tooltipLookup.js";
37
+ export { buildKeySegment, joinKeySegments } from "./customTooltip/tooltipKey.js";
38
+ export { composeCustomTooltipSectionHtml } from "./customTooltip/composeSectionHtml.js";
39
+ export { useTooltipLookup, useTooltipLookupExecutions, } from "./customTooltip/useTooltipLookupExecutions.js";
@@ -16,10 +16,14 @@ import { IColorAssignment } from '@gooddata/sdk-ui';
16
16
  import { IColorPalette } from '@gooddata/sdk-model';
17
17
  import { IColorPaletteItem } from '@gooddata/sdk-model';
18
18
  import { IDataView } from '@gooddata/sdk-backend-spi';
19
+ import { IExecutionDefinition } from '@gooddata/sdk-model';
20
+ import { IExecutionFactory } from '@gooddata/sdk-backend-spi';
19
21
  import { IHeaderPredicate } from '@gooddata/sdk-ui';
20
22
  import { IMappingHeader } from '@gooddata/sdk-ui';
21
23
  import { IMeasure } from '@gooddata/sdk-model';
24
+ import { IPreparedExecution } from '@gooddata/sdk-backend-spi';
22
25
  import { IRgbColorValue } from '@gooddata/sdk-model';
26
+ import { ISeparators } from '@gooddata/sdk-model';
23
27
  import { ITheme } from '@gooddata/sdk-model';
24
28
  import { JSX } from 'react/jsx-runtime';
25
29
  import { NamedExoticComponent } from 'react';
@@ -34,6 +38,39 @@ export declare class AttributeColorStrategy extends ColorStrategy {
34
38
  protected createColorAssignment(colorPalette: IColorPalette, colorMapping: IColorMapping[] | undefined, viewByAttribute: any, stackByAttribute: any, dv: DataViewFacade): ICreateColorAssignmentReturnValue;
35
39
  }
36
40
 
41
+ /**
42
+ * Lookup-key helpers shared by `buildLookupTable` and chart-family hover-time
43
+ * key builders. Both sides must produce identical strings.
44
+ *
45
+ * Format: `${displayFormId}:${uri}` per attribute, joined by `|` after
46
+ * lexicographic sort. The `displayFormId` is always the idRef identifier —
47
+ * uriRef-backed display forms are skipped at the tooltip-execution-planning
48
+ * step (in each chart family's adapter), so they never reach this key builder.
49
+ * Name is omitted: the backend substitutes null/empty names with localized
50
+ * strings only on the display side, so including them here causes
51
+ * lookup-vs-hover mismatches on null/empty rows.
52
+ */
53
+ /**
54
+ * @internal
55
+ */
56
+ export declare function buildKeySegment(displayFormId: string, uri: string): string;
57
+
58
+ /**
59
+ * Build a per-data-point lookup keyed by `${displayFormId}:${uri}` segments
60
+ * (joined by `|`, sorted). Iteration is orientation-agnostic via slices/series.
61
+ *
62
+ * @internal
63
+ */
64
+ export declare function buildLookupTable(dataView: IDataView, meta: ITooltipExecutionMeta, separators?: ISeparators, localizedStrings?: ITooltipLookupLocalizedStrings): Map<string, IResolvedReferenceValues>;
65
+
66
+ /**
67
+ * Returns `null` when the content has no references or all references are
68
+ * already in the chart (resolvable from drill data without a secondary call).
69
+ *
70
+ * @internal
71
+ */
72
+ export declare function buildTooltipExecution(executionFactory: IExecutionFactory, chartDefinition: IExecutionDefinition, tooltipContent: string, options?: IBuildTooltipExecutionOptions): ITooltipExecutionBundle | null;
73
+
37
74
  /**
38
75
  * @internal
39
76
  */
@@ -84,6 +121,16 @@ export declare const ColorUtils: {
84
121
  getColorMappingPredicate: typeof getColorMappingPredicate;
85
122
  };
86
123
 
124
+ /**
125
+ * Merge order matters: in-chart values override external ones, because the
126
+ * in-chart value is what the user already sees on the rendered point/feature.
127
+ * If a tooltip references the same id from both, showing the external (fetched)
128
+ * value would let it drift from what the chart pixel displays.
129
+ *
130
+ * @internal
131
+ */
132
+ export declare function composeCustomTooltipSectionHtml(content: string, inChartValues: IResolvedReferenceValues, externalValues: IResolvedReferenceValues, fallbackText: string): string;
133
+
87
134
  /**
88
135
  * Placement of the custom tooltip section relative to the default tooltip content.
89
136
  *
@@ -250,6 +297,18 @@ export declare function HeadlinePagination({ renderSecondaryItem, renderTertiary
250
297
  */
251
298
  export declare const HeatmapLegend: NamedExoticComponent<IHeatmapLegendProps>;
252
299
 
300
+ /**
301
+ * @internal
302
+ */
303
+ export declare interface IBuildTooltipExecutionOptions {
304
+ /**
305
+ * LocalIdentifiers from `definition.attributes` to use as the row dimension,
306
+ * in the desired order. Omit to use all attributes in definition order
307
+ * (Highcharts default). Geo passes an explicit list to drop position attrs.
308
+ */
309
+ slicingAttributeLocalIds?: readonly string[];
310
+ }
311
+
253
312
  /**
254
313
  * Chart fill config is used to customize the chart fill.
255
314
  *
@@ -366,7 +425,9 @@ export declare interface ICustomTooltipConfig {
366
425
  * - Bold (`**text**`), italic (`*text*`)
367
426
  * - Unordered lists (`- item`) and ordered lists (`1. item`) — not nested
368
427
  * - Images (`![alt](url)`) — `https:`, `http:`, and `data:image/...` URLs only
369
- * - Links (`[text](url)`) — rendered as styled text, NOT clickable inside tooltips
428
+ * - Links (`[text](url)`) — `http(s)` URLs only; rendered as anchors opening in
429
+ * a new tab. End-users can only reach them when the tooltip stays open long
430
+ * enough to interact with (accessible/sticky Highcharts tooltips, geo popups).
370
431
  * - Horizontal rules (`---`)
371
432
  * - Backslash escapes (`\*`, `\_`, `\[`, `\!`, etc.) to render a metacharacter
372
433
  * as literal text instead of formatting
@@ -378,6 +439,12 @@ export declare interface ICustomTooltipConfig {
378
439
  * automatically backslash-escaped, so data containing markdown metacharacters
379
440
  * renders as literal text — no manual escaping is required.
380
441
  *
442
+ * Use display-form identifiers (NOT parent attribute identifiers) inside
443
+ * `{label/id}`. An attribute id renders correctly for attributes that are
444
+ * already in the chart, but it cannot be fetched as a label for external
445
+ * attributes — and a single such ref causes the secondary tooltip fetch
446
+ * to fail backend-side, dropping every other external ref alongside it.
447
+ *
381
448
  * @see https://www.gooddata.com/docs/cloud/create-visualizations/custom-tooltips/
382
449
  */
383
450
  content?: string;
@@ -717,6 +784,70 @@ export declare function isValidMappedColor(colorItem: IColor | null | undefined,
717
784
  */
718
785
  export declare type ItemBorderRadiusPredicate = (item: any) => boolean;
719
786
 
787
+ /**
788
+ * Prepared tooltip execution paired with the meta needed to interpret its result.
789
+ * Carry them together — meta from one call mis-interprets results from another.
790
+ *
791
+ * @internal
792
+ */
793
+ export declare interface ITooltipExecutionBundle {
794
+ execution: IPreparedExecution;
795
+ meta: ITooltipExecutionMeta;
796
+ }
797
+
798
+ /**
799
+ * Maps used by `buildLookupTable` to interpret the execution result.
800
+ *
801
+ * @internal
802
+ */
803
+ export declare interface ITooltipExecutionMeta {
804
+ /** value localId → count localId, for "Multiple items" detection. */
805
+ labelCountMap: Record<string, string>;
806
+ /** tooltip metric localId → LDM measure identifier. */
807
+ measureIdMap: Record<string, string>;
808
+ /** label value localId → LDM label identifier. */
809
+ labelIdMap: Record<string, string>;
810
+ }
811
+
812
+ /**
813
+ * One prepared tooltip execution paired with a caller-owned key and the
814
+ * context that travels with the built lookup.
815
+ *
816
+ * @internal
817
+ */
818
+ export declare interface ITooltipLookupExecutionEntry<TContext> {
819
+ key: string;
820
+ execution: IPreparedExecution;
821
+ meta: ITooltipExecutionMeta;
822
+ context: TContext;
823
+ }
824
+
825
+ /**
826
+ * Built lookup for one tooltip execution entry.
827
+ *
828
+ * @internal
829
+ */
830
+ export declare interface ITooltipLookupExecutionResult<TContext> {
831
+ lookup: Map<string, IResolvedReferenceValues>;
832
+ context: TContext;
833
+ }
834
+
835
+ /**
836
+ * Localized placeholders for unresolved reference values. Mirrors the RichText
837
+ * widget's `richText.no_data` / `richText.multiple_data` messages.
838
+ *
839
+ * @internal
840
+ */
841
+ export declare interface ITooltipLookupLocalizedStrings {
842
+ noData: string;
843
+ multipleItems: string;
844
+ }
845
+
846
+ /**
847
+ * @internal
848
+ */
849
+ export declare function joinKeySegments(parts: readonly string[]): string;
850
+
720
851
  /**
721
852
  * @internal
722
853
  */
@@ -867,6 +998,25 @@ export declare const StaticLegend: NamedExoticComponent<IStaticLegendProps>;
867
998
  */
868
999
  export declare const SupportedLegendPositions: PositionType[];
869
1000
 
1001
+ /**
1002
+ * Single-bundle variant for chart families that have one tooltip execution per
1003
+ * chart (e.g. Highcharts). Returns `undefined` while no bundle is provided or
1004
+ * before the first result lands; consumers handle that as "no external values".
1005
+ *
1006
+ * @internal
1007
+ */
1008
+ export declare function useTooltipLookup(bundle: ITooltipExecutionBundle | undefined, separators?: ISeparators, localizedStrings?: ITooltipLookupLocalizedStrings): Map<string, IResolvedReferenceValues> | undefined;
1009
+
1010
+ /**
1011
+ * Multi-bundle variant for chart families that key tooltip executions per
1012
+ * sub-target (e.g. geo per-layer). `context` is required so the produced
1013
+ * lookup carries whatever the caller needs to interpret the result —
1014
+ * downstream code does not have to defensively check for missing context.
1015
+ *
1016
+ * @internal
1017
+ */
1018
+ export declare function useTooltipLookupExecutions<TContext>(entries: readonly ITooltipLookupExecutionEntry<TContext>[], separators?: ISeparators, localizedStrings?: ITooltipLookupLocalizedStrings): Map<string, ITooltipLookupExecutionResult<TContext>>;
1019
+
870
1020
  /**
871
1021
  * Returns the value if it is non-empty or a fallback text.
872
1022
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gooddata/sdk-ui-vis-commons",
3
- "version": "11.39.0-alpha.0",
3
+ "version": "11.39.0-alpha.2",
4
4
  "description": "GoodData.UI SDK - common functionality for different types of visualizations",
5
5
  "license": "MIT",
6
6
  "author": "GoodData Corporation",
@@ -36,11 +36,11 @@
36
36
  "react-intl": "7.1.11",
37
37
  "react-measure": "^2.5.2",
38
38
  "tslib": "2.8.1",
39
- "@gooddata/sdk-model": "11.39.0-alpha.0",
40
- "@gooddata/sdk-ui": "11.39.0-alpha.0",
41
- "@gooddata/sdk-ui-kit": "11.39.0-alpha.0",
42
- "@gooddata/sdk-backend-spi": "11.39.0-alpha.0",
43
- "@gooddata/sdk-ui-theme-provider": "11.39.0-alpha.0"
39
+ "@gooddata/sdk-model": "11.39.0-alpha.2",
40
+ "@gooddata/sdk-ui": "11.39.0-alpha.2",
41
+ "@gooddata/sdk-backend-spi": "11.39.0-alpha.2",
42
+ "@gooddata/sdk-ui-kit": "11.39.0-alpha.2",
43
+ "@gooddata/sdk-ui-theme-provider": "11.39.0-alpha.2"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@microsoft/api-documenter": "^7.17.0",
@@ -81,11 +81,11 @@
81
81
  "typescript": "5.9.3",
82
82
  "vitest": "4.1.0",
83
83
  "vitest-dom": "0.1.1",
84
- "@gooddata/eslint-config": "11.39.0-alpha.0",
85
- "@gooddata/oxlint-config": "11.39.0-alpha.0",
86
- "@gooddata/reference-workspace": "11.39.0-alpha.0",
87
- "@gooddata/sdk-backend-mockingbird": "11.39.0-alpha.0",
88
- "@gooddata/stylelint-config": "11.39.0-alpha.0"
84
+ "@gooddata/oxlint-config": "11.39.0-alpha.2",
85
+ "@gooddata/eslint-config": "11.39.0-alpha.2",
86
+ "@gooddata/reference-workspace": "11.39.0-alpha.2",
87
+ "@gooddata/sdk-backend-mockingbird": "11.39.0-alpha.2",
88
+ "@gooddata/stylelint-config": "11.39.0-alpha.2"
89
89
  },
90
90
  "peerDependencies": {
91
91
  "react": "^18.0.0 || ^19.0.0",
@@ -4,62 +4,66 @@
4
4
  // containers across chart families (Highcharts, geo, etc.).
5
5
 
6
6
  .gd-viz-tooltip-custom-section {
7
- font-size: 13px;
8
- line-height: 1.4;
7
+ font-size: 14px;
8
+ line-height: 1.2;
9
9
  text-align: left;
10
10
  white-space: normal;
11
11
  word-wrap: break-word;
12
12
  min-width: 200px;
13
+ color: var(--gd-palette-complementary-7);
13
14
 
14
15
  h1,
15
16
  h2,
16
17
  h3,
17
18
  h4 {
18
- margin: 0 0 2px;
19
+ margin: 0;
19
20
  font-weight: 700;
20
- color: var(--gd-chart-tooltip-valueColor);
21
+ color: var(--gd-palette-complementary-9);
21
22
  font-size: 14px;
22
- line-height: 1.3;
23
+ line-height: 1.2;
24
+ padding: 5px 0;
23
25
  }
24
26
 
25
27
  p {
26
- margin: 0 0 1px;
28
+ margin: 0;
29
+ padding-block-end: 5px;
27
30
  }
28
31
 
29
32
  strong {
30
33
  font-weight: 700;
31
- color: var(--gd-chart-tooltip-valueColor);
32
34
  }
33
35
 
34
36
  ul,
35
37
  ol {
36
- margin: 2px 0;
37
- padding-left: 18px;
38
- }
39
-
40
- li {
41
- margin-bottom: 1px;
38
+ padding: 0 0 0 18px;
39
+ margin: 0;
42
40
  }
43
41
 
44
42
  hr {
45
43
  border: 0;
46
44
  border-top: 1px solid var(--gd-palette-complementary-3);
47
- margin: 4px 0;
45
+ padding: 4px 0;
48
46
  }
49
47
 
50
48
  img {
51
49
  max-width: 100%;
52
50
  display: block;
53
- margin: 4px 0;
51
+ padding: 3px 0;
54
52
  }
55
53
 
56
- .gd-viz-tooltip-custom-link {
54
+ a {
57
55
  text-decoration: underline;
58
- color: var(--gd-chart-tooltip-labelColor-from-theme);
56
+ color: inherit;
57
+ cursor: pointer;
58
+ pointer-events: auto;
59
+
60
+ &:hover {
61
+ color: var(--gd-palette-primary-base);
62
+ }
59
63
  }
60
64
  }
61
65
 
62
66
  .gd-viz-tooltip-custom-separator {
63
67
  border-top: 1px solid var(--gd-palette-complementary-3);
64
- margin: 8px 0;
68
+ padding: 7px 0;
65
69
  }