Compare commits

...

34 Commits

Author SHA1 Message Date
Maxime Beauchemin
fa05dc705f fix(table): fall back to datasource columns for conditional formatting when query results are empty
When a Table chart is filtered to show no results, the conditional
formatting panel was showing no columns because it relied exclusively on
queriesResponse colnames/coltypes. This falls back to datasource schema
columns when query results are unavailable, so users can still configure
conditional formatting rules.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 00:19:09 +00:00
Richard Fogaca Nienkotter
de98fdc37b test(heatmap): restore buildQuery coverage on master (#39329) 2026-04-13 13:50:11 -03:00
Maxime Beauchemin
fa1f12a0b5 fix(explore): replace TableView with virtualized GridTable, add row limit controls, restore sample filters (#39212)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 08:19:49 -07:00
Maxime Beauchemin
de40b58e10 fix(tests): fix async teardown leak in FiltersConfigModal.test.tsx (#39281)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 12:48:01 -07:00
Mike Bridge
eea3557f61 fix(dashboard): hide "Filters out of scope" section when empty (#39201)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
2026-04-10 15:42:41 -04:00
Mike Bridge
7a243d329e fix(dashboard): allow filter list to scroll in filter config modal sidebar (#39203)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
2026-04-10 15:42:16 -04:00
Maxime Beauchemin
98146251c4 fix(tests): improve ShareMenuItems test isolation to fix intermittent suite failure (#39280)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 12:30:38 -07:00
Maxime Beauchemin
0aa8cace1b fix(dataset-editor): fix SQL expression editor extra spaces and height expansion (#39248)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 12:12:26 -07:00
Maxime Beauchemin
450701ecec fix(SqlLab): improve SQL diff modal — responsive width, padding, tabs, and copy button (#39246)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 12:11:05 -07:00
Richard Fogaca Nienkotter
e9911fbac4 fix(echarts): prevent tooltip crash during dashboard auto-refresh (#39277)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:36:44 -03:00
Gabriel Torres Ruiz
69c8eef78e fix(ag-grid): jpeg export of ag-grid tables (#38781) 2026-04-10 12:54:59 -03:00
dependabot[bot]
2ff50667e7 chore(deps): bump axios from 1.13.5 to 1.15.0 in /superset-frontend (#39258)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 20:55:13 +07:00
dependabot[bot]
f1cf274751 chore(deps): bump axios from 1.13.5 to 1.15.0 in /docs (#39259)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 20:48:35 +07:00
dependabot[bot]
b65396ccd4 chore(deps-dev): bump @types/node from 25.5.2 to 25.6.0 in /superset-websocket (#39262)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 20:46:57 +07:00
dependabot[bot]
1ad76e847e chore(deps-dev): bump prettier from 3.8.1 to 3.8.2 in /superset-websocket (#39260)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 20:46:34 +07:00
dependabot[bot]
4583ef93a4 chore(deps-dev): bump prettier from 3.8.1 to 3.8.2 in /superset-frontend (#39263)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 20:40:46 +07:00
dependabot[bot]
f632d2474b chore(deps-dev): bump webpack from 5.105.4 to 5.106.0 in /superset-frontend (#39268)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 20:40:03 +07:00
Evan Rusackas
b1d69f5b39 docs(api): add Theme API endpoints to OpenAPI spec (#37943)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-10 00:17:06 -07:00
Enzo Martellucci
aba7e6dae4 fix(table): cross-filtering breaks after renaming column labels via Custom SQL (#38858) 2026-04-10 06:02:18 +02:00
Mike Bridge
8bcc90c766 fix(dashboard): Vertical filter bar gradient is extending past the filter bar area (#39204)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
2026-04-09 18:30:47 -07:00
venkateshwaran shanmugham
e39dd1afce fix: implement native browser fullscreen for dashboard charts (#38819)
Signed-off-by: Venkateshwaran Shanmugham <venkateshwaracholan@gmail.com>
Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>
Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
Co-authored-by: Richard Fogaça <richardfogaca@gmail.com>
Co-authored-by: Richard Fogaca Nienkotter <63572350+richardfogaca@users.noreply.github.com>
2026-04-09 21:49:36 -03:00
Amin Ghadersohi
680cef0ee0 fix(mcp): strip json_metadata and position_json from get_dashboard_info response (#39101) 2026-04-09 17:30:57 -04:00
Amin Ghadersohi
e17cf3c808 fix(mcp): wire up compact schema serialization for search_tools results (#39229) 2026-04-09 17:25:46 -04:00
Shaitan
f49310b8ff fix(sql-lab): apply access check in SqlExecutionResultsCommand (#38952)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:47:15 -04:00
Vitor Avila
c7955a38ef fix: Drill to Detail for Embedded (#39214)
Co-authored-by: Maxime Beauchemin <maximebeauchemin@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 17:01:48 -03:00
Amin Ghadersohi
68067d7f44 fix(mcp): handle OAuth-authenticated databases in execute_sql (#39166) 2026-04-09 15:47:00 -04:00
Daniel Vaz Gaspar
5815665cc6 feat: role/user CRUD events and login/logout tracking in the action log (#39121)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 15:55:25 +01:00
Enzo Martellucci
6649f35a0d fix(reports): escape SQL LIKE wildcards in find_by_extra_metadata (#38738)
Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
2026-04-09 12:58:06 +03:00
Mehmet Salih Yavuz
5263abdc60 fix(AlertsReports): untie filters from alerts reports tabs flag (#38722)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:11:43 +03:00
Birk Skyum
c49641538d feat: modernize deck.gl and map plugins with MapLibre/Mapbox dual renderer (#38035)
Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>
2026-04-08 20:14:59 -04:00
Maxime Beauchemin
d915e4f3ff fix(tags): fix Bulk tag modal dropdown clipping and stale tag cache (#39210)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 16:28:13 -07:00
Maxime Beauchemin
bad5a35fce fix(explore): constrain Edit Dataset modal height to prevent footer cutoff (#39211)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 16:19:10 -07:00
Amin Ghadersohi
1bde6f3bfd fix(mcp): resolve null fields in list_datasets, list_databases, and save_sql_query (#39206) 2026-04-08 18:39:56 -04:00
Deadman
4e0890ee1f feat(api): Add filter_dashboard_id parameter to apply dashboard filters to chart/data endpoint (#38638)
Co-authored-by: Matthew Deadman <matthewdeadman@Matthews-MacBook-Pro-2.local>
Co-authored-by: Matthew Deadman <matthewdeadman@matthews-mbp-2.lan>
Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
2026-04-08 15:32:46 -07:00
354 changed files with 14491 additions and 4280 deletions

View File

@@ -412,7 +412,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|--------|----------|-------------|
| `GET` | [Get security roles](/developer-docs/api/get-security-roles) | `/api/v1/security/roles/` |
| `POST` | [Create security roles](/developer-docs/api/create-security-roles) | `/api/v1/security/roles/` |
| `GET` | [Get security roles info](/developer-docs/api/get-security-roles-info) | `/api/v1/security/roles/_info` |
| `GET` | [Get security roles info](/developer-docs/api/get-security-roles-info) | `/api/v1/security/roles/_info` |
| `DELETE` | [Delete security roles by pk](/developer-docs/api/delete-security-roles-by-pk) | `/api/v1/security/roles/{pk}` |
| `GET` | [Get security roles by pk](/developer-docs/api/get-security-roles-by-pk) | `/api/v1/security/roles/{pk}` |
| `PUT` | [Update security roles by pk](/developer-docs/api/update-security-roles-by-pk) | `/api/v1/security/roles/{pk}` |
@@ -430,7 +430,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|--------|----------|-------------|
| `GET` | [Get security users](/developer-docs/api/get-security-users) | `/api/v1/security/users/` |
| `POST` | [Create security users](/developer-docs/api/create-security-users) | `/api/v1/security/users/` |
| `GET` | [Get security users info](/developer-docs/api/get-security-users-info) | `/api/v1/security/users/_info` |
| `GET` | [Get security users info](/developer-docs/api/get-security-users-info) | `/api/v1/security/users/_info` |
| `DELETE` | [Delete security users by pk](/developer-docs/api/delete-security-users-by-pk) | `/api/v1/security/users/{pk}` |
| `GET` | [Get security users by pk](/developer-docs/api/get-security-users-by-pk) | `/api/v1/security/users/{pk}` |
| `PUT` | [Update security users by pk](/developer-docs/api/update-security-users-by-pk) | `/api/v1/security/users/{pk}` |
@@ -443,7 +443,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | [Get security permissions](/developer-docs/api/get-security-permissions) | `/api/v1/security/permissions/` |
| `GET` | [Get security permissions info](/developer-docs/api/get-security-permissions-info) | `/api/v1/security/permissions/_info` |
| `GET` | [Get security permissions info](/developer-docs/api/get-security-permissions-info) | `/api/v1/security/permissions/_info` |
| `GET` | [Get security permissions by pk](/developer-docs/api/get-security-permissions-by-pk) | `/api/v1/security/permissions/{pk}` |
</details>
@@ -455,7 +455,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|--------|----------|-------------|
| `GET` | [Get security resources](/developer-docs/api/get-security-resources) | `/api/v1/security/resources/` |
| `POST` | [Create security resources](/developer-docs/api/create-security-resources) | `/api/v1/security/resources/` |
| `GET` | [Get security resources info](/developer-docs/api/get-security-resources-info) | `/api/v1/security/resources/_info` |
| `GET` | [Get security resources info](/developer-docs/api/get-security-resources-info) | `/api/v1/security/resources/_info` |
| `DELETE` | [Delete security resources by pk](/developer-docs/api/delete-security-resources-by-pk) | `/api/v1/security/resources/{pk}` |
| `GET` | [Get security resources by pk](/developer-docs/api/get-security-resources-by-pk) | `/api/v1/security/resources/{pk}` |
| `PUT` | [Update security resources by pk](/developer-docs/api/update-security-resources-by-pk) | `/api/v1/security/resources/{pk}` |
@@ -469,7 +469,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|--------|----------|-------------|
| `GET` | [Get security permissions resources](/developer-docs/api/get-security-permissions-resources) | `/api/v1/security/permissions-resources/` |
| `POST` | [Create security permissions resources](/developer-docs/api/create-security-permissions-resources) | `/api/v1/security/permissions-resources/` |
| `GET` | [Get security permissions resources info](/developer-docs/api/get-security-permissions-resources-info) | `/api/v1/security/permissions-resources/_info` |
| `GET` | [Get security permissions resources info](/developer-docs/api/get-security-permissions-resources-info) | `/api/v1/security/permissions-resources/_info` |
| `DELETE` | [Delete security permissions resources by pk](/developer-docs/api/delete-security-permissions-resources-by-pk) | `/api/v1/security/permissions-resources/{pk}` |
| `GET` | [Get security permissions resources by pk](/developer-docs/api/get-security-permissions-resources-by-pk) | `/api/v1/security/permissions-resources/{pk}` |
| `PUT` | [Update security permissions resources by pk](/developer-docs/api/update-security-permissions-resources-by-pk) | `/api/v1/security/permissions-resources/{pk}` |
@@ -578,7 +578,29 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | [Get api by version openapi](/developer-docs/api/get-api-by-version-openapi) | `/api/{version}/_openapi` |
| `GET` | [Get api by version openapi](/developer-docs/api/get-api-by-version-openapi) | `/api/{version}/_openapi` |
</details>
<details>
<summary><strong>Themes</strong> (14 endpoints) — Manage UI themes for customizing Superset's appearance.</summary>
| Method | Endpoint | Description |
|--------|----------|-------------|
| `DELETE` | [Bulk delete themes](/developer-docs/api/bulk-delete-themes) | `/api/v1/theme/` |
| `GET` | [Get a list of themes](/developer-docs/api/get-a-list-of-themes) | `/api/v1/theme/` |
| `POST` | [Create a theme](/developer-docs/api/create-a-theme) | `/api/v1/theme/` |
| `GET` | [Get metadata information about this API resource (theme-info)](/developer-docs/api/get-metadata-information-about-this-api-resource-theme-info) | `/api/v1/theme/_info` |
| `DELETE` | [Delete a theme](/developer-docs/api/delete-a-theme) | `/api/v1/theme/{pk}` |
| `GET` | [Get a theme](/developer-docs/api/get-a-theme) | `/api/v1/theme/{pk}` |
| `PUT` | [Update a theme](/developer-docs/api/update-a-theme) | `/api/v1/theme/{pk}` |
| `PUT` | [Set a theme as the system dark theme](/developer-docs/api/set-a-theme-as-the-system-dark-theme) | `/api/v1/theme/{pk}/set_system_dark` |
| `PUT` | [Set a theme as the system default theme](/developer-docs/api/set-a-theme-as-the-system-default-theme) | `/api/v1/theme/{pk}/set_system_default` |
| `GET` | [Download multiple themes as YAML files](/developer-docs/api/download-multiple-themes-as-yaml-files) | `/api/v1/theme/export/` |
| `POST` | [Import themes from a ZIP file](/developer-docs/api/import-themes-from-a-zip-file) | `/api/v1/theme/import/` |
| `GET` | [Get related fields data (theme-related-column-name)](/developer-docs/api/get-related-fields-data-theme-related-column-name) | `/api/v1/theme/related/{column_name}` |
| `DELETE` | [Clear the system dark theme](/developer-docs/api/clear-the-system-dark-theme) | `/api/v1/theme/unset_system_dark` |
| `DELETE` | [Clear the system default theme](/developer-docs/api/clear-the-system-default-theme) | `/api/v1/theme/unset_system_default` |
</details>

View File

@@ -129,6 +129,30 @@ def add_missing_schemas(spec: dict[str, Any]) -> tuple[dict[str, Any], list[str]
}
fixed.append("DashboardColorsConfigUpdateSchema")
# DashboardChartCustomizationsConfigUpdateSchema (dashboards/schemas.py)
if "DashboardChartCustomizationsConfigUpdateSchema" not in schemas:
schemas["DashboardChartCustomizationsConfigUpdateSchema"] = {
"type": "object",
"properties": {
"deleted": {
"type": "array",
"items": {"type": "string"},
"description": "List of deleted chart customization IDs.",
},
"modified": {
"type": "array",
"items": {"type": "object"},
"description": "List of modified chart customizations.",
},
"reordered": {
"type": "array",
"items": {"type": "string"},
"description": "List of chart customization IDs in new order.",
},
},
}
fixed.append("DashboardChartCustomizationsConfigUpdateSchema")
# FormatQueryPayloadSchema - based on superset/sqllab/schemas.py
if "FormatQueryPayloadSchema" not in schemas:
schemas["FormatQueryPayloadSchema"] = {
@@ -295,6 +319,7 @@ TAG_DESCRIPTIONS = {
"Security Roles": "Manage security roles and their permissions.",
"Security Users": "Manage user accounts.",
"Tags": "Organize assets with tags.",
"Themes": "Manage UI themes for customizing Superset's appearance.",
"User": "User profile and preferences.",
}

File diff suppressed because it is too large Load Diff

View File

@@ -5743,13 +5743,13 @@ available-typed-arrays@^1.0.7:
possible-typed-array-names "^1.0.0"
axios@^1.12.2:
version "1.13.5"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.5.tgz#5e464688fa127e11a660a2c49441c009f6567a43"
integrity sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==
version "1.15.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.15.0.tgz#0fcee91ef03d386514474904b27863b2c683bf4f"
integrity sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==
dependencies:
follow-redirects "^1.15.11"
form-data "^4.0.5"
proxy-from-env "^1.1.0"
proxy-from-env "^2.1.0"
babel-loader@^9.2.1:
version "9.2.1"
@@ -12643,10 +12643,10 @@ proxy-addr@~2.0.7:
forwarded "0.2.0"
ipaddr.js "1.9.1"
proxy-from-env@^1.1.0:
version "1.1.0"
resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
proxy-from-env@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba"
integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==
punycode@^1.4.1:
version "1.4.1"

View File

@@ -48,7 +48,7 @@ dependencies = [
"cryptography>=42.0.4, <47.0.0",
"deprecation>=2.1.0, <2.2.0",
"flask>=2.2.5, <4.0.0",
"flask-appbuilder>=5.0.2,<6",
"flask-appbuilder>=5.2.1, <6.0.0",
"flask-caching>=2.1.0, <3",
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",

View File

@@ -120,7 +120,7 @@ flask==2.3.3
# flask-session
# flask-sqlalchemy
# flask-wtf
flask-appbuilder==5.2.0
flask-appbuilder==5.2.1
# via
# apache-superset (pyproject.toml)
# apache-superset-core

View File

@@ -259,7 +259,7 @@ flask==2.3.3
# flask-sqlalchemy
# flask-testing
# flask-wtf
flask-appbuilder==5.2.0
flask-appbuilder==5.2.1
# via
# -c requirements/base-constraint.txt
# apache-superset

File diff suppressed because it is too large Load Diff

View File

@@ -128,17 +128,17 @@
"@superset-ui/legacy-plugin-chart-chord": "file:./plugins/legacy-plugin-chart-chord",
"@superset-ui/legacy-plugin-chart-country-map": "file:./plugins/legacy-plugin-chart-country-map",
"@superset-ui/legacy-plugin-chart-horizon": "file:./plugins/legacy-plugin-chart-horizon",
"@superset-ui/legacy-plugin-chart-map-box": "file:./plugins/legacy-plugin-chart-map-box",
"@superset-ui/legacy-plugin-chart-paired-t-test": "file:./plugins/legacy-plugin-chart-paired-t-test",
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "file:./plugins/legacy-plugin-chart-parallel-coordinates",
"@superset-ui/legacy-plugin-chart-partition": "file:./plugins/legacy-plugin-chart-partition",
"@superset-ui/legacy-plugin-chart-rose": "file:./plugins/legacy-plugin-chart-rose",
"@superset-ui/legacy-plugin-chart-world-map": "file:./plugins/legacy-plugin-chart-world-map",
"@superset-ui/legacy-preset-chart-deckgl": "file:./plugins/legacy-preset-chart-deckgl",
"@superset-ui/preset-chart-deckgl": "file:./plugins/preset-chart-deckgl",
"@superset-ui/legacy-preset-chart-nvd3": "file:./plugins/legacy-preset-chart-nvd3",
"@superset-ui/plugin-chart-ag-grid-table": "file:./plugins/plugin-chart-ag-grid-table",
"@superset-ui/plugin-chart-cartodiagram": "file:./plugins/plugin-chart-cartodiagram",
"@superset-ui/plugin-chart-echarts": "file:./plugins/plugin-chart-echarts",
"@superset-ui/plugin-chart-point-cluster-map": "file:./plugins/plugin-chart-point-cluster-map",
"@superset-ui/plugin-chart-handlebars": "file:./plugins/plugin-chart-handlebars",
"@superset-ui/plugin-chart-pivot-table": "file:./plugins/plugin-chart-pivot-table",
"@superset-ui/plugin-chart-table": "file:./plugins/plugin-chart-table",
@@ -347,7 +347,7 @@
"open-cli": "^9.0.0",
"oxlint": "^1.56.0",
"po2json": "^0.4.5",
"prettier": "3.8.1",
"prettier": "3.8.2",
"prettier-plugin-packagejson": "^3.0.2",
"process": "^0.11.10",
"react-refresh": "^0.18.0",
@@ -368,7 +368,7 @@
"unzipper": "^0.12.3",
"vm-browserify": "^1.1.2",
"wait-on": "^9.0.4",
"webpack": "^5.105.4",
"webpack": "^5.106.0",
"webpack-bundle-analyzer": "^5.3.0",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.3",

View File

@@ -41,6 +41,7 @@ export enum VizType {
LegacyBubble = 'bubble',
Line = 'echarts_timeseries_line',
MapBox = 'mapbox',
PointClusterMap = 'point_cluster_map',
MixedTimeseries = 'mixed_timeseries',
PairedTTest = 'paired_ttest',
ParallelCoordinates = 'para',

View File

@@ -33,20 +33,22 @@ import type { PlaceholderProps } from './types';
function DefaultPlaceholder({
width,
height,
showLoadingForImport = false,
showLoadingForImport = true,
placeholderStyle: style,
}: PlaceholderProps) {
return (
// since `width` defaults to 100%, we can display the placeholder once
// height is specified.
(height && (
if (showLoadingForImport) {
return (
<div key="async-asm-placeholder" style={{ width, height, ...style }}>
{showLoadingForImport && <Loading position="floating" />}
<Loading position="floating" size="s" />
</div>
)) ||
// `|| null` is for in case of height=0.
null
);
);
}
if (height) {
return (
<div key="async-asm-placeholder" style={{ width, height, ...style }} />
);
}
return null;
}
/**

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from '../../spec';
import { render, screen, fireEvent } from '../../spec';
import CodeSyntaxHighlighter from './index';
// Simple mock that just returns the content
@@ -153,4 +153,44 @@ describe('CodeSyntaxHighlighter', () => {
expect(screen.getByText('SELECT * FROM users;')).toBeInTheDocument();
});
test('shows copy button by default', () => {
render(
<CodeSyntaxHighlighter language="sql">SELECT 1;</CodeSyntaxHighlighter>,
);
expect(screen.getByTitle('Copy to clipboard')).toBeInTheDocument();
});
test('hides copy button when showCopyButton is false', () => {
render(
<CodeSyntaxHighlighter language="sql" showCopyButton={false}>
SELECT 1;
</CodeSyntaxHighlighter>,
);
expect(screen.queryByTitle('Copy to clipboard')).not.toBeInTheDocument();
});
test('copy button does not throw when clipboard API is unavailable', () => {
const originalClipboard = navigator.clipboard;
Object.defineProperty(navigator, 'clipboard', {
value: undefined,
configurable: true,
});
document.execCommand = jest.fn().mockReturnValue(true);
render(
<CodeSyntaxHighlighter language="sql">SELECT 1;</CodeSyntaxHighlighter>,
);
expect(() =>
fireEvent.click(screen.getByTitle('Copy to clipboard')),
).not.toThrow();
Object.defineProperty(navigator, 'clipboard', {
value: originalClipboard,
configurable: true,
});
});
});

View File

@@ -16,11 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import SyntaxHighlighterBase from 'react-syntax-highlighter/dist/cjs/light';
import github from 'react-syntax-highlighter/dist/cjs/styles/hljs/github';
import tomorrow from 'react-syntax-highlighter/dist/cjs/styles/hljs/tomorrow-night';
import { isThemeDark, useTheme } from '@apache-superset/core/theme';
import { css, isThemeDark, useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import copyTextToClipboard from '../../utils/copy';
import { Icons } from '../Icons';
export type SupportedLanguage = 'sql' | 'htmlbars' | 'markdown' | 'json';
@@ -31,6 +34,7 @@ export interface CodeSyntaxHighlighterProps {
showLineNumbers?: boolean;
wrapLines?: boolean;
style?: any; // Override theme style if needed
showCopyButton?: boolean;
}
// Track which languages have been registered to avoid duplicate registrations
@@ -76,11 +80,14 @@ export const CodeSyntaxHighlighter: React.FC<CodeSyntaxHighlighterProps> = ({
showLineNumbers = false,
wrapLines = true,
style: overrideStyle,
showCopyButton = true,
}) => {
const theme = useTheme();
const [isLanguageReady, setIsLanguageReady] = useState(
registeredLanguages.has(language),
);
const [copied, setCopied] = useState(false);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const loadLanguage = async () => {
@@ -93,6 +100,21 @@ export const CodeSyntaxHighlighter: React.FC<CodeSyntaxHighlighterProps> = ({
loadLanguage();
}, [language]);
useEffect(
() => () => {
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
},
[],
);
const handleCopy = useCallback(() => {
copyTextToClipboard(() => Promise.resolve(children)).then(() => {
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
setCopied(true);
copyTimeoutRef.current = setTimeout(() => setCopied(false), 1500);
});
}, [children]);
const isDark = isThemeDark(theme);
const themeStyle = overrideStyle || (isDark ? tomorrow : github);
@@ -104,32 +126,79 @@ export const CodeSyntaxHighlighter: React.FC<CodeSyntaxHighlighterProps> = ({
...customStyle,
};
const copyButton = showCopyButton && (
<button
type="button"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
handleCopy();
}}
title={copied ? t('Copied!') : t('Copy to clipboard')}
css={css`
position: absolute;
top: ${theme.sizeUnit}px;
right: ${theme.sizeUnit}px;
background: none;
border: none;
cursor: pointer;
padding: ${theme.sizeUnit}px;
color: ${copied ? theme.colorSuccess : theme.colorTextSecondary};
line-height: 1;
border-radius: ${theme.borderRadius}px;
&:hover {
color: ${copied ? theme.colorSuccess : theme.colorText};
background: ${theme.colorBgTextHover};
}
`}
>
{copied ? (
<Icons.CheckOutlined style={{ fontSize: theme.fontSizeSM }} />
) : (
<Icons.CopyOutlined style={{ fontSize: theme.fontSizeSM }} />
)}
</button>
);
// Show a simple pre-formatted text while language is loading
if (!isLanguageReady) {
return (
<pre
style={{
...defaultCustomStyle,
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
margin: 0,
}}
<div
css={css`
position: relative;
`}
>
{children}
</pre>
{copyButton}
<pre
style={{
...defaultCustomStyle,
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
margin: 0,
}}
>
{children}
</pre>
</div>
);
}
return (
<SyntaxHighlighterBase
language={language}
style={themeStyle}
customStyle={defaultCustomStyle}
showLineNumbers={showLineNumbers}
wrapLines={wrapLines}
<div
css={css`
position: relative;
`}
>
{children}
</SyntaxHighlighterBase>
{copyButton}
<SyntaxHighlighterBase
language={language}
style={themeStyle}
customStyle={defaultCustomStyle}
showLineNumbers={showLineNumbers}
wrapLines={wrapLines}
>
{children}
</SyntaxHighlighterBase>
</div>
);
};

View File

@@ -158,11 +158,13 @@ test('passes all props through to AgGridReact', () => {
/>,
);
// onGridReady and onFirstDataRendered are intercepted by the component to expose
// the grid API on the container element; the wrapped function is passed instead.
expect(AgGridReact).toHaveBeenCalledWith(
expect.objectContaining({
rowData: mockRowData,
columnDefs: mockColumnDefs,
onGridReady,
onGridReady: expect.any(Function),
onCellClicked,
pagination: true,
paginationPageSize: 10,
@@ -171,6 +173,47 @@ test('passes all props through to AgGridReact', () => {
);
});
test('onGridReady wrapper calls user callback and exposes api on container', () => {
const onGridReady = jest.fn();
render(
<ThemedAgGridReact
rowData={mockRowData}
columnDefs={mockColumnDefs}
onGridReady={onGridReady}
/>,
);
// Retrieve the wrapped handler that was passed to AgGridReact
const lastCall = (AgGridReact as jest.Mock).mock.calls.at(-1)[0];
const wrappedOnGridReady = lastCall.onGridReady as Function;
const mockApi = { setGridOption: jest.fn() };
wrappedOnGridReady({ api: mockApi });
// The user-provided callback must be forwarded
expect(onGridReady).toHaveBeenCalledWith({ api: mockApi });
});
test('onFirstDataRendered wrapper calls user callback', () => {
const onFirstDataRendered = jest.fn();
render(
<ThemedAgGridReact
rowData={mockRowData}
columnDefs={mockColumnDefs}
onFirstDataRendered={onFirstDataRendered}
/>,
);
const lastCall = (AgGridReact as jest.Mock).mock.calls.at(-1)[0];
const wrappedOnFirstDataRendered = lastCall.onFirstDataRendered as Function;
wrappedOnFirstDataRendered({ firstRow: 0 });
expect(onFirstDataRendered).toHaveBeenCalledWith({ firstRow: 0 });
});
test('applies custom theme colors from Superset theme', () => {
const customTheme = {
...supersetTheme,

View File

@@ -16,19 +16,28 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useMemo, forwardRef } from 'react';
import { useMemo, useRef, useCallback, forwardRef } from 'react';
import { css } from '@emotion/react';
import { AgGridReact, type AgGridReactProps } from 'ag-grid-react';
import {
themeQuartz,
colorSchemeDark,
colorSchemeLight,
type GridApi,
type GridReadyEvent,
type FirstDataRenderedEvent,
} from 'ag-grid-community';
import { useTheme, useThemeMode } from '@apache-superset/core/theme';
// Note: With ag-grid v34's new theming API, CSS files are injected automatically
// Do NOT import 'ag-grid-community/styles/ag-grid.css' or theme CSS files
// Extends HTMLDivElement with ag-grid state attached to the container for downloadAsImage.
export interface AgGridContainerElement extends HTMLDivElement {
_agGridApi?: GridApi;
_agGridFirstDataRendered?: boolean;
}
export interface ThemedAgGridReactProps extends AgGridReactProps {
/**
* Optional theme parameter overrides to customize specific ag-grid theme values.
@@ -71,9 +80,13 @@ export interface ThemedAgGridReactProps extends AgGridReactProps {
export const ThemedAgGridReact = forwardRef<
AgGridReact,
ThemedAgGridReactProps
>(function ThemedAgGridReact({ themeOverrides, ...props }, ref) {
>(function ThemedAgGridReact(
{ themeOverrides, onGridReady, onFirstDataRendered, ...props },
ref,
) {
const theme = useTheme();
const isDarkMode = useThemeMode();
const containerRef = useRef<AgGridContainerElement>(null);
// Get the appropriate ag-grid theme based on dark/light mode
const agGridTheme = useMemo(() => {
@@ -140,8 +153,32 @@ export const ThemedAgGridReact = forwardRef<
return baseTheme.withParams(finalParams);
}, [theme, isDarkMode, themeOverrides]);
// Expose gridApi and first-data-rendered flag on the container for downloadAsImage.
const handleGridReady = useCallback(
(event: GridReadyEvent) => {
if (containerRef.current) {
containerRef.current._agGridFirstDataRendered = false;
containerRef.current._agGridApi = event.api;
}
onGridReady?.(event);
},
[onGridReady],
);
// Mark the container once rows are painted so downloadAsImage can gate on readiness.
const handleFirstDataRendered = useCallback(
(event: FirstDataRenderedEvent) => {
if (containerRef.current) {
containerRef.current._agGridFirstDataRendered = true;
}
onFirstDataRendered?.(event);
},
[onFirstDataRendered],
);
return (
<div
ref={containerRef}
css={css`
width: 100%;
height: 100%;
@@ -151,7 +188,13 @@ export const ThemedAgGridReact = forwardRef<
`}
data-themed-ag-grid="true"
>
<AgGridReact ref={ref} theme={agGridTheme} {...props} />
<AgGridReact
ref={ref}
theme={agGridTheme}
onGridReady={handleGridReady}
onFirstDataRendered={handleFirstDataRendered}
{...props}
/>
</div>
);
});

View File

@@ -201,6 +201,7 @@ export * from './Result';
export {
ThemedAgGridReact,
type ThemedAgGridReactProps,
type AgGridContainerElement,
setupAGGridModules,
defaultModules,
} from './ThemedAgGridReact';

View File

@@ -0,0 +1,98 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
const isSafari = (): boolean => {
const { userAgent } = navigator;
return Boolean(userAgent && /^((?!chrome|android).)*safari/i.test(userAgent));
};
// Use the new Clipboard API if the browser supports it
const copyTextWithClipboardApi = async (getText: () => Promise<string>) => {
// Safari (WebKit) does not support delayed generation of clipboard.
// This means that writing to the clipboard, from the moment the user
// interacts with the app, must be instantaneous.
// However, neither writeText nor write accepts a Promise, so
// we need to create a ClipboardItem that accepts said Promise to
// delay the text generation, as needed.
// Source: https://bugs.webkit.org/show_bug.cgi?id=222262P
if (isSafari()) {
try {
const clipboardItem = new ClipboardItem({
'text/plain': getText(),
});
await navigator.clipboard.write([clipboardItem]);
} catch {
// Fallback to default clipboard API implementation
const text = await getText();
await navigator.clipboard.writeText(text);
}
} else {
// For Blink, the above method won't work, but we can use the
// default (intended) API, since the delayed generation of the
// clipboard is now supported.
// Source: https://bugs.chromium.org/p/chromium/issues/detail?id=1014310
const text = await getText();
await navigator.clipboard.writeText(text);
}
};
const copyTextToClipboard = (getText: () => Promise<string>) =>
copyTextWithClipboardApi(getText)
// If the Clipboard API is not supported, fallback to the older method.
.catch(() =>
getText().then(
text =>
new Promise<void>((resolve, reject) => {
const selection: Selection | null = document.getSelection();
if (selection) {
selection.removeAllRanges();
const range = document.createRange();
const span = document.createElement('span');
span.textContent = text;
span.style.position = 'fixed';
span.style.top = '0';
span.style.clip = 'rect(0, 0, 0, 0)';
span.style.whiteSpace = 'pre';
document.body.appendChild(span);
range.selectNode(span);
selection.addRange(range);
try {
if (!document.execCommand('copy')) {
reject();
}
} catch (err) {
reject();
}
document.body.removeChild(span);
if (selection.removeRange) {
selection.removeRange(range);
} else {
selection.removeAllRanges();
}
}
resolve();
}),
),
);
export default copyTextToClipboard;

View File

@@ -17,6 +17,7 @@
* under the License.
*/
export { default as convertKeysToCamelCase } from './convertKeysToCamelCase';
export { default as copyTextToClipboard } from './copy';
export { default as ensureIsArray } from './ensureIsArray';
export { default as ensureIsInt } from './ensureIsInt';
export { default as isDefined } from './isDefined';

View File

@@ -1,55 +0,0 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.20.0](https://github.com/apache/superset/compare/v2021.41.0...v0.20.0) (2024-09-09)
### Bug Fixes
- **deckgl:** deckgl unable to load map ([#17851](https://github.com/apache/superset/issues/17851)) ([52f5dcb](https://github.com/apache/superset/commit/52f5dcb58eec7b188f4387b8781dcda4252a5680))
- **explore:** Prevent shared controls from checking feature flags outside React render ([#21315](https://github.com/apache/superset/issues/21315)) ([2285ebe](https://github.com/apache/superset/commit/2285ebe72ec4edded6d195052740b7f9f13d1f1b))
### Features
- apply standardized form data to tier 2 charts ([#20530](https://github.com/apache/superset/issues/20530)) ([de524bc](https://github.com/apache/superset/commit/de524bc59f011fd361dcdb7d35c2cb51f7eba442))
- **deckgl-map:** use an arbitraty Mabpox style URL ([#26027](https://github.com/apache/superset/issues/26027)) ([#26031](https://github.com/apache/superset/issues/26031)) ([af58784](https://github.com/apache/superset/commit/af587840403d83a7da7fb0f57bc10ad2335d4eeb))
# [0.19.0](https://github.com/apache/superset/compare/v2021.41.0...v0.19.0) (2024-09-07)
### Bug Fixes
- **deckgl:** deckgl unable to load map ([#17851](https://github.com/apache/superset/issues/17851)) ([52f5dcb](https://github.com/apache/superset/commit/52f5dcb58eec7b188f4387b8781dcda4252a5680))
- **explore:** Prevent shared controls from checking feature flags outside React render ([#21315](https://github.com/apache/superset/issues/21315)) ([2285ebe](https://github.com/apache/superset/commit/2285ebe72ec4edded6d195052740b7f9f13d1f1b))
### Features
- apply standardized form data to tier 2 charts ([#20530](https://github.com/apache/superset/issues/20530)) ([de524bc](https://github.com/apache/superset/commit/de524bc59f011fd361dcdb7d35c2cb51f7eba442))
- **deckgl-map:** use an arbitraty Mabpox style URL ([#26027](https://github.com/apache/superset/issues/26027)) ([#26031](https://github.com/apache/superset/issues/26031)) ([af58784](https://github.com/apache/superset/commit/af587840403d83a7da7fb0f57bc10ad2335d4eeb))
# [0.18.0](https://github.com/apache-superset/superset-ui/compare/v0.17.87...v0.18.0) (2021-08-30)
**Note:** Version bump only for package @superset-ui/legacy-plugin-chart-map-box
## [0.17.61](https://github.com/apache-superset/superset-ui/compare/v0.17.60...v0.17.61) (2021-07-02)
**Note:** Version bump only for package @superset-ui/legacy-plugin-chart-map-box

View File

@@ -1,52 +0,0 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
## @superset-ui/legacy-plugin-chart-map-box
[![Version](https://img.shields.io/npm/v/@superset-ui/legacy-plugin-chart-map-box.svg?style=flat)](https://www.npmjs.com/package/@superset-ui/legacy-plugin-chart-map-box)
[![Libraries.io](https://img.shields.io/librariesio/release/npm/%40superset-ui%2Flegacy-plugin-chart-map-box?style=flat)](https://libraries.io/npm/@superset-ui%2Flegacy-plugin-chart-map-box)
This plugin provides MapBox for Superset.
### Usage
Configure `key`, which can be any `string`, and register the plugin. This `key` will be used to
lookup this chart throughout the app.
```js
import MapBoxChartPlugin from '@superset-ui/legacy-plugin-chart-map-box';
new MapBoxChartPlugin().configure({ key: 'map-box' }).register();
```
Then use it via `SuperChart`. See
[storybook](https://apache-superset.github.io/superset-ui-plugins/?selectedKind=plugin-chart-map-box)
for more details.
```js
<SuperChart
chartType="map-box"
width={600}
height={600}
formData={...}
queriesData={[{
data: {...},
}]}
/>
```

View File

@@ -1,243 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable react/jsx-sort-default-props, react/sort-prop-types */
/* eslint-disable react/forbid-prop-types, react/require-default-props */
import { Component } from 'react';
import MapGL from 'react-map-gl';
import { WebMercatorViewport } from '@math.gl/web-mercator';
import ScatterPlotGlowOverlay from './ScatterPlotGlowOverlay';
import './MapBox.css';
const NOOP = () => {};
export const DEFAULT_MAX_ZOOM = 16;
export const DEFAULT_POINT_RADIUS = 60;
interface Viewport {
longitude: number;
latitude: number;
zoom: number;
isDragging?: boolean;
}
interface Clusterer {
getClusters(bbox: number[], zoom: number): GeoJSONLocation[];
}
interface GeoJSONLocation {
geometry: {
coordinates: [number, number];
};
properties: Record<string, number | string | boolean | null | undefined>;
}
interface MapBoxProps {
width?: number;
height?: number;
aggregatorName?: string;
clusterer: Clusterer; // Required - used for getClusters()
globalOpacity?: number;
hasCustomMetric?: boolean;
mapStyle?: string;
mapboxApiKey: string;
onViewportChange?: (viewport: Viewport) => void;
pointRadius?: number;
pointRadiusUnit?: string;
renderWhileDragging?: boolean;
rgb?: (string | number)[];
bounds?: [[number, number], [number, number]]; // May be undefined for empty datasets
viewportLongitude?: number;
viewportLatitude?: number;
viewportZoom?: number;
}
interface MapBoxState {
viewport: Viewport;
}
const defaultProps: Partial<MapBoxProps> = {
width: 400,
height: 400,
globalOpacity: 1,
onViewportChange: NOOP,
pointRadius: DEFAULT_POINT_RADIUS,
pointRadiusUnit: 'Pixels',
};
class MapBox extends Component<MapBoxProps, MapBoxState> {
static defaultProps = defaultProps;
constructor(props: MapBoxProps) {
super(props);
const fitBounds = this.computeFitBoundsViewport();
this.state = {
viewport: this.mergeViewportWithProps(fitBounds),
};
this.handleViewportChange = this.handleViewportChange.bind(this);
}
handleViewportChange(viewport: Viewport) {
this.setState({ viewport });
const { onViewportChange } = this.props;
onViewportChange!(viewport);
}
mergeViewportWithProps(
fitBounds: Viewport,
viewport: Viewport = fitBounds,
props: MapBoxProps = this.props,
useFitBoundsForUnset = true,
): Viewport {
const { viewportLongitude, viewportLatitude, viewportZoom } = props;
return {
...viewport,
longitude:
viewportLongitude ??
(useFitBoundsForUnset ? fitBounds.longitude : viewport.longitude),
latitude:
viewportLatitude ??
(useFitBoundsForUnset ? fitBounds.latitude : viewport.latitude),
zoom:
viewportZoom ?? (useFitBoundsForUnset ? fitBounds.zoom : viewport.zoom),
};
}
computeFitBoundsViewport(): Viewport {
const { width = 400, height = 400, bounds } = this.props;
if (bounds && bounds[0] && bounds[1]) {
const mercator = new WebMercatorViewport({ width, height }).fitBounds(
bounds,
);
return {
latitude: mercator.latitude,
longitude: mercator.longitude,
zoom: mercator.zoom,
};
}
return { latitude: 0, longitude: 0, zoom: 1 };
}
componentDidUpdate(prevProps: MapBoxProps) {
const { viewport } = this.state;
const fitBoundsInputsChanged =
prevProps.width !== this.props.width ||
prevProps.height !== this.props.height ||
prevProps.bounds !== this.props.bounds;
const viewportPropsChanged =
prevProps.viewportLongitude !== this.props.viewportLongitude ||
prevProps.viewportLatitude !== this.props.viewportLatitude ||
prevProps.viewportZoom !== this.props.viewportZoom;
if (!fitBoundsInputsChanged && !viewportPropsChanged) {
return;
}
const fitBounds = this.computeFitBoundsViewport();
const nextViewport = this.mergeViewportWithProps(
fitBounds,
viewport,
this.props,
fitBoundsInputsChanged || viewportPropsChanged,
);
const viewportChanged =
nextViewport.longitude !== viewport.longitude ||
nextViewport.latitude !== viewport.latitude ||
nextViewport.zoom !== viewport.zoom;
if (viewportChanged) {
this.setState({ viewport: nextViewport });
}
}
render() {
const {
width,
height,
aggregatorName,
clusterer,
globalOpacity,
mapStyle,
mapboxApiKey,
pointRadius,
pointRadiusUnit,
renderWhileDragging,
rgb,
hasCustomMetric,
bounds,
} = this.props;
const { viewport } = this.state;
const isDragging =
viewport.isDragging === undefined ? false : viewport.isDragging;
// Compute the clusters based on the original bounds and current zoom level. Note when zoom/pan
// to an area outside of the original bounds, no additional queries are made to the backend to
// retrieve additional data.
// add this variable to widen the visible area
const offsetHorizontal = ((width ?? 400) * 0.5) / 100;
const offsetVertical = ((height ?? 400) * 0.5) / 100;
// Guard against empty datasets where bounds may be undefined
const bbox =
bounds && bounds[0] && bounds[1]
? [
bounds[0][0] - offsetHorizontal,
bounds[0][1] - offsetVertical,
bounds[1][0] + offsetHorizontal,
bounds[1][1] + offsetVertical,
]
: [-180, -90, 180, 90]; // Default to world bounds
const clusters = clusterer.getClusters(bbox, Math.round(viewport.zoom));
return (
<MapGL
{...viewport}
mapStyle={mapStyle}
width={width}
height={height}
mapboxApiAccessToken={mapboxApiKey}
onViewportChange={this.handleViewportChange}
preserveDrawingBuffer
>
<ScatterPlotGlowOverlay
{...viewport}
isDragging={isDragging}
locations={clusters}
dotRadius={pointRadius}
pointRadiusUnit={pointRadiusUnit}
rgb={rgb}
globalOpacity={globalOpacity}
compositeOperation="screen"
renderWhileDragging={renderWhileDragging}
aggregation={hasCustomMetric ? aggregatorName : undefined}
lngLatAccessor={(location: GeoJSONLocation) => {
const { coordinates } = location.geometry;
return [coordinates[0], coordinates[1]];
}}
/>
</MapGL>
);
}
}
export default MapBox;

View File

@@ -1,425 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable react/require-default-props */
import { PureComponent } from 'react';
import { CanvasOverlay } from 'react-map-gl';
import { kmToPixels, MILES_PER_KM } from './utils/geo';
import roundDecimal from './utils/roundDecimal';
import luminanceFromRGB from './utils/luminanceFromRGB';
import 'mapbox-gl/dist/mapbox-gl.css';
// Shared radius bounds keep cluster and point sizing in sync.
export const MIN_CLUSTER_RADIUS_RATIO = 1 / 6;
export const MAX_POINT_RADIUS_RATIO = 1 / 3;
interface GeoJSONLocation {
geometry: {
coordinates: [number, number];
};
properties: Record<string, number | string | boolean | null | undefined>;
}
interface RedrawParams {
width: number;
height: number;
ctx: CanvasRenderingContext2D;
isDragging: boolean;
project: (lngLat: [number, number]) => [number, number];
}
interface DrawTextOptions {
fontHeight?: number;
label?: string | number;
radius?: number;
rgb?: (string | number)[];
shadow?: boolean;
}
interface ScatterPlotGlowOverlayProps {
aggregation?: string;
compositeOperation?: string;
dotRadius?: number;
globalOpacity?: number;
lngLatAccessor?: (location: GeoJSONLocation) => [number, number];
locations: GeoJSONLocation[];
pointRadiusUnit?: string;
renderWhileDragging?: boolean;
rgb?: (string | number)[];
zoom?: number;
isDragging?: boolean;
}
const defaultProps: Partial<ScatterPlotGlowOverlayProps> = {
// Same as browser default.
compositeOperation: 'source-over',
dotRadius: 4,
lngLatAccessor: (location: GeoJSONLocation) => [
location.geometry.coordinates[0],
location.geometry.coordinates[1],
],
renderWhileDragging: true,
};
const computeClusterLabel = (
properties: Record<string, number | string | boolean | null | undefined>,
aggregation: string | undefined,
): number | string => {
const count = properties.point_count as number;
if (!aggregation) {
return count;
}
if (aggregation === 'sum' || aggregation === 'min' || aggregation === 'max') {
return properties[aggregation] as number;
}
const { sum } = properties as { sum: number };
const mean = sum / count;
if (aggregation === 'mean') {
return Math.round(100 * mean) / 100;
}
const { squaredSum } = properties as { squaredSum: number };
const variance = squaredSum / count - (sum / count) ** 2;
if (aggregation === 'var') {
return Math.round(100 * variance) / 100;
}
if (aggregation === 'stdev') {
return Math.round(100 * Math.sqrt(variance)) / 100;
}
// fallback to point_count, this really shouldn't happen
return count;
};
class ScatterPlotGlowOverlay extends PureComponent<ScatterPlotGlowOverlayProps> {
static defaultProps = defaultProps;
constructor(props: ScatterPlotGlowOverlayProps) {
super(props);
this.redraw = this.redraw.bind(this);
}
drawText(
ctx: CanvasRenderingContext2D,
pixel: [number, number],
options: DrawTextOptions = {},
) {
const IS_DARK_THRESHOLD = 110;
const {
fontHeight = 0,
label = '',
radius = 0,
rgb = [0, 0, 0],
shadow = false,
} = options;
const maxWidth = radius * 1.8;
const luminance = luminanceFromRGB(
rgb[1] as number,
rgb[2] as number,
rgb[3] as number,
);
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = luminance <= IS_DARK_THRESHOLD ? 'white' : 'black';
ctx.font = `${fontHeight}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (shadow) {
ctx.shadowBlur = 15;
ctx.shadowColor = luminance <= IS_DARK_THRESHOLD ? 'black' : '';
}
const textWidth = ctx.measureText(String(label)).width;
if (textWidth > maxWidth) {
const scale = fontHeight / textWidth;
ctx.font = `${scale * maxWidth}px sans-serif`;
}
const { compositeOperation } = this.props;
ctx.fillText(String(label), pixel[0], pixel[1]);
ctx.globalCompositeOperation = (compositeOperation ??
'source-over') as GlobalCompositeOperation;
ctx.shadowBlur = 0;
ctx.shadowColor = '';
}
// Modified: https://github.com/uber/react-map-gl/blob/master/overlays/scatterplot.react.js
redraw({ width, height, ctx, isDragging, project }: RedrawParams) {
const {
aggregation,
compositeOperation,
dotRadius,
globalOpacity,
lngLatAccessor,
locations,
pointRadiusUnit,
renderWhileDragging,
rgb,
zoom,
} = this.props;
const radius = dotRadius ?? 4;
const clusterLabelMap: (number | string)[] = [];
locations.forEach((location, i) => {
if (location.properties.cluster) {
clusterLabelMap[i] = computeClusterLabel(
location.properties,
aggregation,
);
}
});
const finiteClusterLabels = clusterLabelMap
.map(value => Number(value))
.filter(value => Number.isFinite(value));
const safeMaxAbsLabel =
finiteClusterLabels.length > 0
? Math.max(
Math.max(...finiteClusterLabels.map(value => Math.abs(value))),
1,
)
: 1;
// Calculate min/max radius values for Pixels mode scaling
let minRadiusValue = Infinity;
let maxRadiusValue = -Infinity;
if (pointRadiusUnit === 'Pixels') {
locations.forEach(location => {
// Accept both null and undefined as "no value" and coerce potential numeric strings
if (
!location.properties.cluster &&
location.properties.radius != null
) {
const radiusValueRaw = location.properties.radius;
const radiusValue =
typeof radiusValueRaw === 'string' && radiusValueRaw.trim() === ''
? null
: Number(radiusValueRaw);
if (radiusValue != null && Number.isFinite(radiusValue)) {
minRadiusValue = Math.min(minRadiusValue, radiusValue);
maxRadiusValue = Math.max(maxRadiusValue, radiusValue);
}
}
});
}
ctx.clearRect(0, 0, width, height);
ctx.globalCompositeOperation = (compositeOperation ??
'source-over') as GlobalCompositeOperation;
if ((renderWhileDragging || !isDragging) && locations) {
locations.forEach(function _forEach(
this: ScatterPlotGlowOverlay,
location: GeoJSONLocation,
i: number,
) {
const pixel = project(lngLatAccessor!(location)) as [number, number];
const pixelRounded: [number, number] = [
roundDecimal(pixel[0], 1),
roundDecimal(pixel[1], 1),
];
if (
pixelRounded[0] + radius >= 0 &&
pixelRounded[0] - radius < width &&
pixelRounded[1] + radius >= 0 &&
pixelRounded[1] - radius < height
) {
ctx.beginPath();
if (location.properties.cluster) {
const clusterLabel = clusterLabelMap[i];
// Validate clusterLabel is a finite number before using it for radius calculation
const numericLabel = Number(clusterLabel);
const safeNumericLabel = Number.isFinite(numericLabel)
? numericLabel
: 0;
const minClusterRadius =
pointRadiusUnit === 'Pixels'
? radius * MAX_POINT_RADIUS_RATIO
: radius * MIN_CLUSTER_RADIUS_RATIO;
const ratio = Math.abs(safeNumericLabel) / safeMaxAbsLabel;
const scaledRadius = roundDecimal(
minClusterRadius + ratio ** 0.5 * (radius - minClusterRadius),
1,
);
const fontHeight = roundDecimal(scaledRadius * 0.5, 1);
const [x, y] = pixelRounded;
const gradient = ctx.createRadialGradient(
x,
y,
scaledRadius,
x,
y,
0,
);
gradient.addColorStop(
1,
`rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${0.8 * (globalOpacity ?? 1)})`,
);
gradient.addColorStop(
0,
`rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, 0)`,
);
ctx.arc(
pixelRounded[0],
pixelRounded[1],
scaledRadius,
0,
Math.PI * 2,
);
ctx.fillStyle = gradient;
ctx.fill();
if (Number.isFinite(safeNumericLabel)) {
let label: string | number = clusterLabel;
const absLabel = Math.abs(safeNumericLabel);
const sign = safeNumericLabel < 0 ? '-' : '';
if (absLabel >= 10000) {
label = `${sign}${Math.round(absLabel / 1000)}k`;
} else if (absLabel >= 1000) {
label = `${sign}${Math.round(absLabel / 100) / 10}k`;
}
this.drawText(ctx, pixelRounded, {
fontHeight,
label,
radius: scaledRadius,
rgb,
shadow: true,
});
}
} else {
const defaultRadius = radius * MIN_CLUSTER_RADIUS_RATIO;
const rawRadius = location.properties.radius;
const numericRadiusProperty =
rawRadius != null &&
!(typeof rawRadius === 'string' && rawRadius.trim() === '')
? Number(rawRadius)
: null;
const radiusProperty =
numericRadiusProperty != null &&
Number.isFinite(numericRadiusProperty)
? numericRadiusProperty
: null;
const pointMetric = location.properties.metric ?? null;
let pointRadius: number = radiusProperty ?? defaultRadius;
let pointLabel: string | number | undefined;
if (radiusProperty != null) {
const pointLatitude = lngLatAccessor!(location)[1];
if (pointRadiusUnit === 'Kilometers') {
pointLabel = `${roundDecimal(pointRadius, 2)}km`;
pointRadius = kmToPixels(pointRadius, pointLatitude, zoom ?? 0);
} else if (pointRadiusUnit === 'Miles') {
pointLabel = `${roundDecimal(pointRadius, 2)}mi`;
pointRadius = kmToPixels(
pointRadius * MILES_PER_KM,
pointLatitude,
zoom ?? 0,
);
} else if (pointRadiusUnit === 'Pixels') {
// Scale pixel values to a reasonable range (radius/6 to radius/3)
// This ensures points are visible and proportional to their values
const MIN_POINT_RADIUS = radius * MIN_CLUSTER_RADIUS_RATIO;
const MAX_POINT_RADIUS = radius * MAX_POINT_RADIUS_RATIO;
if (
Number.isFinite(minRadiusValue) &&
Number.isFinite(maxRadiusValue) &&
maxRadiusValue > minRadiusValue
) {
// Normalize the value to 0-1 range, then scale to pixel range
const numericPointRadius = Number(pointRadius);
if (!Number.isFinite(numericPointRadius)) {
// fallback to minimum visible size when the value is not a finite number
pointRadius = MIN_POINT_RADIUS;
} else {
const normalizedValueRaw =
(numericPointRadius - minRadiusValue) /
(maxRadiusValue - minRadiusValue);
const normalizedValue = Math.max(
0,
Math.min(1, normalizedValueRaw),
);
pointRadius =
MIN_POINT_RADIUS +
normalizedValue * (MAX_POINT_RADIUS - MIN_POINT_RADIUS);
}
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
} else if (
Number.isFinite(minRadiusValue) &&
minRadiusValue === maxRadiusValue
) {
// All values are the same, use a fixed medium size
pointRadius = (MIN_POINT_RADIUS + MAX_POINT_RADIUS) / 2;
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
} else {
// Use raw pixel values if they're already in a reasonable range
pointRadius = Math.max(
MIN_POINT_RADIUS,
Math.min(pointRadius, MAX_POINT_RADIUS),
);
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
}
}
}
if (pointMetric !== null) {
const numericMetric = parseFloat(String(pointMetric));
pointLabel = Number.isFinite(numericMetric)
? roundDecimal(numericMetric, 2)
: String(pointMetric);
}
// Fall back to default points if pointRadius wasn't a numerical column
if (!pointRadius) {
pointRadius = defaultRadius;
}
ctx.arc(
pixelRounded[0],
pixelRounded[1],
roundDecimal(pointRadius, 1),
0,
Math.PI * 2,
);
ctx.fillStyle = `rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${globalOpacity})`;
ctx.fill();
if (pointLabel !== undefined) {
this.drawText(ctx, pixelRounded, {
fontHeight: roundDecimal(pointRadius, 1),
label: pointLabel,
radius: pointRadius,
rgb,
shadow: false,
});
}
}
}
}, this);
}
}
render() {
return <CanvasOverlay redraw={this.redraw} />;
}
}
export default ScatterPlotGlowOverlay;

View File

@@ -1,107 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable no-magic-numbers */
import { SuperChart } from '@superset-ui/core';
import { useTheme } from '@apache-superset/core/theme';
import MapBoxChartPlugin from '@superset-ui/legacy-plugin-chart-map-box';
import { withResizableChartDemo } from '@storybook-shared';
import { generateData } from './data';
new MapBoxChartPlugin().configure({ key: 'map-box' }).register();
export default {
title: 'Legacy Chart Plugins/legacy-plugin-chart-map-box',
decorators: [withResizableChartDemo],
args: {
clusteringRadius: 60,
globalOpacity: 1,
pointRadius: 'Auto',
renderWhileDragging: true,
},
argTypes: {
clusteringRadius: {
control: { type: 'range', min: 0, max: 200, step: 10 },
description: 'Radius in pixels for clustering points',
},
globalOpacity: {
control: { type: 'range', min: 0, max: 1, step: 0.1 },
description: 'Opacity of map markers',
},
pointRadius: {
control: 'select',
options: ['Auto', 1, 2, 5, 10, 20, 50],
description: 'Size of point markers',
},
renderWhileDragging: {
control: 'boolean',
description: 'Render markers while dragging the map',
},
},
parameters: {
docs: {
description: {
component:
'Note: This chart requires a Mapbox API key to display. Without a valid key, the map background will not render.',
},
},
},
};
export const MapBoxViz = ({
clusteringRadius,
globalOpacity,
pointRadius,
renderWhileDragging,
width,
height,
}: {
clusteringRadius: number;
globalOpacity: number;
pointRadius: string | number;
renderWhileDragging: boolean;
width: number;
height: number;
}) => {
const theme = useTheme();
return (
<SuperChart
chartType="map-box"
width={width}
height={height}
queriesData={[{ data: generateData(theme) }]}
formData={{
all_columns_x: 'LON',
all_columns_y: 'LAT',
clustering_radius: String(clusteringRadius),
global_opacity: globalOpacity,
mapbox_color: 'rgb(244, 176, 42)',
mapbox_label: [],
mapbox_style: 'mapbox://styles/mapbox/light-v9',
pandas_aggfunc: 'sum',
point_radius: pointRadius,
point_radius_unit: 'Pixels',
render_while_dragging: renderWhileDragging,
viewport_latitude: 37.78711146014447,
viewport_longitude: -122.37633433151713,
viewport_zoom: 10.026425338292782,
}}
/>
);
};

View File

@@ -1,162 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import Supercluster, {
type Options as SuperclusterOptions,
} from 'supercluster';
import { ChartProps } from '@superset-ui/core';
import { DEFAULT_POINT_RADIUS, DEFAULT_MAX_ZOOM } from './MapBox';
const NOOP = () => {};
const MIN_LONGITUDE = -180;
const MAX_LONGITUDE = 180;
const MIN_LATITUDE = -90;
const MAX_LATITUDE = 90;
const MIN_ZOOM = 0;
function toFiniteNumber(
value: string | number | null | undefined,
): number | undefined {
if (value === null || value === undefined) return undefined;
const normalizedValue = typeof value === 'string' ? value.trim() : value;
if (normalizedValue === '') return undefined;
const num = Number(normalizedValue);
return Number.isFinite(num) ? num : undefined;
}
function clampNumber(
value: number | undefined,
min: number,
max: number,
): number | undefined {
if (value === undefined) return undefined;
return Math.min(max, Math.max(min, value));
}
interface ClusterProperties {
metric: number;
sum: number;
squaredSum: number;
min: number;
max: number;
}
export default function transformProps(chartProps: ChartProps) {
const { width, height, formData, hooks, queriesData } = chartProps;
const { onError = NOOP, setControlValue = NOOP } = hooks;
const { bounds, geoJSON, hasCustomMetric, mapboxApiKey } =
queriesData[0].data;
const {
clusteringRadius,
globalOpacity,
mapboxColor,
mapboxStyle,
pandasAggfunc,
pointRadiusUnit,
renderWhileDragging,
viewportLongitude,
viewportLatitude,
viewportZoom,
} = formData;
// Validate mapbox color
const rgb = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/.exec(mapboxColor);
if (rgb === null) {
onError("Color field must be of form 'rgb(%d, %d, %d)'");
return {};
}
const opts: SuperclusterOptions<ClusterProperties, ClusterProperties> = {
maxZoom: DEFAULT_MAX_ZOOM,
radius: clusteringRadius,
};
if (hasCustomMetric) {
opts.initial = () => ({
metric: 0,
sum: 0,
squaredSum: 0,
min: Infinity,
max: -Infinity,
});
opts.map = (prop: ClusterProperties) => ({
metric: prop.metric,
sum: prop.metric,
squaredSum: prop.metric ** 2,
min: prop.metric,
max: prop.metric,
});
opts.reduce = (accu: ClusterProperties, prop: ClusterProperties) => {
// Temporarily disable param-reassignment linting to work with supercluster's api
/* eslint-disable no-param-reassign */
accu.sum += prop.sum;
accu.squaredSum += prop.squaredSum;
accu.min = Math.min(accu.min, prop.min);
accu.max = Math.max(accu.max, prop.max);
/* eslint-enable no-param-reassign */
};
}
const clusterer = new Supercluster(opts);
clusterer.load(geoJSON.features);
return {
width,
height,
aggregatorName: pandasAggfunc,
bounds,
clusterer,
hasCustomMetric,
mapboxApiKey,
mapStyle: mapboxStyle,
onViewportChange({
latitude,
longitude,
zoom,
}: {
latitude: number;
longitude: number;
zoom: number;
}) {
setControlValue('viewport_longitude', longitude);
setControlValue('viewport_latitude', latitude);
setControlValue('viewport_zoom', zoom);
},
// Always use DEFAULT_POINT_RADIUS as the base radius for cluster sizing
// Individual point radii come from geoJSON properties.radius
pointRadius: DEFAULT_POINT_RADIUS,
pointRadiusUnit,
renderWhileDragging,
rgb,
viewportLongitude: clampNumber(
toFiniteNumber(viewportLongitude),
MIN_LONGITUDE,
MAX_LONGITUDE,
),
viewportLatitude: clampNumber(
toFiniteNumber(viewportLatitude),
MIN_LATITUDE,
MAX_LATITUDE,
),
viewportZoom: clampNumber(
toFiniteNumber(viewportZoom),
MIN_ZOOM,
DEFAULT_MAX_ZOOM,
),
globalOpacity: Math.min(1, Math.max(0, toFiniteNumber(globalOpacity) ?? 1)),
};
}

View File

@@ -1,381 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { type ReactNode } from 'react';
import { render } from '@testing-library/react';
import MapBox from '../src/MapBox';
// Capture the most recent viewport props passed to MapGL
let lastMapGLProps: Record<string, unknown> = {};
const mockFitBounds = jest.fn();
jest.mock('react-map-gl', () => {
const MockMapGL = (props: Record<string, unknown>) => {
lastMapGLProps = props;
return <div data-test="map-gl">{props.children as ReactNode}</div>;
};
return { __esModule: true, default: MockMapGL };
});
jest.mock('@math.gl/web-mercator', () => ({
WebMercatorViewport: jest
.fn()
.mockImplementation(
({ width, height }: { width: number; height: number }) => ({
fitBounds: (bounds: [[number, number], [number, number]]) =>
mockFitBounds(bounds, width, height),
}),
),
}));
jest.mock('../src/ScatterPlotGlowOverlay', () => {
const MockOverlay = (props: Record<string, unknown>) => (
<div data-test="scatter-overlay" data-opacity={props.globalOpacity} />
);
return { __esModule: true, default: MockOverlay };
});
const defaultProps = {
width: 800,
height: 600,
clusterer: {
getClusters: jest.fn().mockReturnValue([]),
},
globalOpacity: 1,
mapboxApiKey: 'test-key',
mapStyle: 'mapbox://styles/mapbox/light-v9',
pointRadius: 60,
pointRadiusUnit: 'Pixels',
renderWhileDragging: true,
rgb: ['', 255, 0, 0] as (string | number)[],
hasCustomMetric: false,
bounds: [
[-74.0, 40.7],
[-73.9, 40.8],
] as [[number, number], [number, number]],
onViewportChange: jest.fn(),
};
beforeEach(() => {
lastMapGLProps = {};
jest.clearAllMocks();
mockFitBounds.mockImplementation(
(
bounds: [[number, number], [number, number]],
width: number,
height: number,
) => ({
latitude: Number(((bounds[0][1] + bounds[1][1]) / 2).toFixed(2)),
longitude: Number(((bounds[0][0] + bounds[1][0]) / 2).toFixed(2)),
zoom: Number((10 + width / 1000 + height / 10000).toFixed(2)),
}),
);
});
test('initializes viewport from bounds', () => {
render(<MapBox {...defaultProps} />);
expect(lastMapGLProps.latitude).toBe(40.75);
expect(lastMapGLProps.longitude).toBe(-73.95);
expect(lastMapGLProps.zoom).toBe(10.86);
});
test('updates viewport when viewport props change', () => {
const { rerender } = render(
<MapBox
{...defaultProps}
viewportLongitude={-73.95}
viewportLatitude={40.75}
viewportZoom={10}
/>,
);
rerender(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
expect(lastMapGLProps.longitude).toBe(-122.4);
expect(lastMapGLProps.latitude).toBe(37.8);
expect(lastMapGLProps.zoom).toBe(5);
});
test('does not loop when viewport state matches new props', () => {
const { rerender } = render(
<MapBox
{...defaultProps}
viewportLongitude={-73.95}
viewportLatitude={40.75}
viewportZoom={10}
/>,
);
// Re-render with same props that match the initial viewport state
rerender(
<MapBox
{...defaultProps}
viewportLongitude={-73.95}
viewportLatitude={40.75}
viewportZoom={10}
/>,
);
// Viewport should still be the fitBounds-computed values since props didn't change
expect(lastMapGLProps.longitude).toBe(-73.95);
expect(lastMapGLProps.latitude).toBe(40.75);
expect(lastMapGLProps.zoom).toBe(10);
});
test('passes globalOpacity to ScatterPlotGlowOverlay', () => {
const { getByTestId } = render(
<MapBox {...defaultProps} globalOpacity={0.5} />,
);
const overlay = getByTestId('scatter-overlay');
expect(overlay.dataset.opacity).toBe('0.5');
});
test('initializes viewport from props when provided', () => {
render(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
expect(lastMapGLProps.longitude).toBe(-122.4);
expect(lastMapGLProps.latitude).toBe(37.8);
expect(lastMapGLProps.zoom).toBe(5);
});
test('handles undefined bounds gracefully', () => {
render(<MapBox {...defaultProps} bounds={undefined} />);
expect(lastMapGLProps.longitude).toBe(0);
expect(lastMapGLProps.latitude).toBe(0);
expect(lastMapGLProps.zoom).toBe(1);
});
test('applies partial viewport props on update', () => {
const { rerender } = render(<MapBox {...defaultProps} />);
rerender(<MapBox {...defaultProps} viewportLongitude={-122.4} />);
expect(lastMapGLProps.longitude).toBe(-122.4);
expect(lastMapGLProps.latitude).toBe(40.75);
expect(lastMapGLProps.zoom).toBe(10.86);
});
test('restores fitBounds when viewport props are cleared', () => {
const { rerender } = render(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
// Clear all viewport props (simulates user clearing the controls)
rerender(<MapBox {...defaultProps} />);
// Should revert to fitBounds values
expect(lastMapGLProps.longitude).toBe(-73.95);
expect(lastMapGLProps.latitude).toBe(40.75);
expect(lastMapGLProps.zoom).toBe(10.86);
});
test('restores only cleared viewport props, keeps the rest', () => {
const { rerender } = render(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
// Clear only longitude, keep lat/zoom
rerender(
<MapBox {...defaultProps} viewportLatitude={37.8} viewportZoom={5} />,
);
// Longitude reverts to fitBounds, lat/zoom stay
expect(lastMapGLProps.longitude).toBe(-73.95);
expect(lastMapGLProps.latitude).toBe(37.8);
expect(lastMapGLProps.zoom).toBe(5);
});
test('applies changed viewport props even when another is cleared simultaneously', () => {
const { rerender } = render(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
// Clear longitude, change latitude simultaneously
rerender(
<MapBox {...defaultProps} viewportLatitude={40.0} viewportZoom={5} />,
);
// Longitude reverts to fitBounds, latitude should be the NEW value
expect(lastMapGLProps.longitude).toBe(-73.95);
expect(lastMapGLProps.latitude).toBe(40.0);
expect(lastMapGLProps.zoom).toBe(5);
});
test('falls back to default viewport when cleared with undefined bounds', () => {
const { rerender } = render(
<MapBox
{...defaultProps}
bounds={undefined}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
// Clear viewport props — no bounds to fitBounds to
rerender(<MapBox {...defaultProps} bounds={undefined} />);
// Should fall back to {0, 0, 1}
expect(lastMapGLProps.longitude).toBe(0);
expect(lastMapGLProps.latitude).toBe(0);
expect(lastMapGLProps.zoom).toBe(1);
});
test('recomputes fitBounds when bounds change and no explicit viewport is set', () => {
const { rerender } = render(<MapBox {...defaultProps} />);
rerender(
<MapBox
{...defaultProps}
bounds={[
[-123.2, 36.5],
[-121.8, 38.1],
]}
/>,
);
expect(lastMapGLProps.longitude).toBe(-122.5);
expect(lastMapGLProps.latitude).toBe(37.3);
expect(lastMapGLProps.zoom).toBe(10.86);
});
test('recomputes fitBounds when chart size changes and no explicit viewport is set', () => {
const { rerender } = render(<MapBox {...defaultProps} />);
rerender(<MapBox {...defaultProps} width={1200} height={900} />);
expect(lastMapGLProps.longitude).toBe(-73.95);
expect(lastMapGLProps.latitude).toBe(40.75);
expect(lastMapGLProps.zoom).toBe(11.29);
});
test('recomputes only implicit viewport fields when bounds change', () => {
const { rerender } = render(
<MapBox {...defaultProps} viewportLongitude={-122.4} />,
);
rerender(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
bounds={[
[-123.2, 36.5],
[-121.8, 38.1],
]}
/>,
);
expect(lastMapGLProps.longitude).toBe(-122.4);
expect(lastMapGLProps.latitude).toBe(37.3);
expect(lastMapGLProps.zoom).toBe(10.86);
});
test('recomputes only implicit viewport fields when chart size changes', () => {
const { rerender } = render(
<MapBox {...defaultProps} viewportLatitude={37.8} />,
);
rerender(
<MapBox
{...defaultProps}
viewportLatitude={37.8}
width={1200}
height={900}
/>,
);
expect(lastMapGLProps.longitude).toBe(-73.95);
expect(lastMapGLProps.latitude).toBe(37.8);
expect(lastMapGLProps.zoom).toBe(11.29);
});
test('recomputes implicit position when zoom stays explicit across bounds changes', () => {
const { rerender } = render(<MapBox {...defaultProps} viewportZoom={5} />);
rerender(
<MapBox
{...defaultProps}
viewportZoom={5}
bounds={[
[-123.2, 36.5],
[-121.8, 38.1],
]}
/>,
);
expect(lastMapGLProps.longitude).toBe(-122.5);
expect(lastMapGLProps.latitude).toBe(37.3);
expect(lastMapGLProps.zoom).toBe(5);
});
test('does not recompute fitBounds on bounds change when an explicit viewport is set', () => {
const { rerender } = render(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
rerender(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
bounds={[
[-123.2, 36.5],
[-121.8, 38.1],
]}
/>,
);
expect(lastMapGLProps.longitude).toBe(-122.4);
expect(lastMapGLProps.latitude).toBe(37.8);
expect(lastMapGLProps.zoom).toBe(5);
});

View File

@@ -1,9 +0,0 @@
{
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": false,
"rootDir": "."
},
"extends": "../../../tsconfig.json",
"include": ["**/*", "../types/**/*", "../../../types/**/*"]
}

View File

@@ -1,25 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
// Path Resolution: Override baseUrl to maintain correct path mappings from parent config
// (e.g., "@apache-superset/core" -> "./packages/superset-core/src")
"baseUrl": "../..",
// Directory Overrides: Parent config paths are relative to frontend root,
// but packages need paths relative to their own directory
"outDir": "lib",
"rootDir": "src",
"declarationDir": "lib"
},
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
"exclude": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.test.*",
"src/**/*.stories.*"],
"references": [
{ "path": "../../packages/superset-core" },
{ "path": "../../packages/superset-ui-core" },
{ "path": "../../packages/superset-ui-chart-controls" }
]
}

View File

@@ -1,101 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
declare module '*.png' {
const value: string;
export default value;
}
declare module '*.jpg' {
const value: string;
export default value;
}
declare module 'supercluster' {
interface Options<P = Record<string, unknown>, C = Record<string, unknown>> {
minZoom?: number;
maxZoom?: number;
minPoints?: number;
radius?: number;
extent?: number;
nodeSize?: number;
log?: boolean;
initial?: () => C;
map?: (props: P) => C;
reduce?: (accumulated: C, props: C) => void;
}
interface GeoJSONFeature {
type: string;
geometry: {
type: string;
coordinates: [number, number];
};
properties: Record<string, unknown>;
}
class Supercluster<P = Record<string, unknown>, C = Record<string, unknown>> {
constructor(options?: Options<P, C>);
load(points: GeoJSONFeature[]): Supercluster<P, C>;
getClusters(bbox: number[], zoom: number): GeoJSONFeature[];
getTile(z: number, x: number, y: number): GeoJSONFeature[] | null;
getChildren(clusterId: number): GeoJSONFeature[];
getLeaves(
clusterId: number,
limit?: number,
offset?: number,
): GeoJSONFeature[];
getClusterExpansionZoom(clusterId: number): number;
}
export default Supercluster;
export { Options, GeoJSONFeature };
}
declare module 'react-map-gl' {
import { Component, ReactNode } from 'react';
interface MapGLProps {
width?: number;
height?: number;
latitude?: number;
longitude?: number;
zoom?: number;
mapStyle?: string;
mapboxApiAccessToken?: string;
onViewportChange?: Function;
preserveDrawingBuffer?: boolean;
children?: ReactNode;
[key: string]: unknown;
}
export default class MapGL extends Component<MapGLProps> {}
interface CanvasOverlayProps {
redraw: (params: {
width: number;
height: number;
ctx: CanvasRenderingContext2D;
isDragging: boolean;
project: (lngLat: [number, number]) => [number, number];
}) => void;
}
export class CanvasOverlay extends Component<CanvasOverlayProps> {}
}

View File

@@ -1,89 +0,0 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.20.4](https://github.com/apache/superset/compare/v0.20.3...v0.20.4) (2024-12-10)
**Note:** Version bump only for package @superset-ui/legacy-preset-chart-deckgl
# [0.20.0](https://github.com/apache/superset/compare/v2021.41.0...v0.20.0) (2024-09-09)
### Bug Fixes
- **Dashboard:** Color inconsistency on refreshes and conflicts ([#27439](https://github.com/apache/superset/issues/27439)) ([313ee59](https://github.com/apache/superset/commit/313ee596f5435894f857d72be7269d5070c8c964))
- deck.gl Geojson path not visible ([#24428](https://github.com/apache/superset/issues/24428)) ([6bb930e](https://github.com/apache/superset/commit/6bb930ef4ed26ea381e7f8e889851aa7867ba0eb))
- deck.gl GeoJsonLayer Autozoom & fill/stroke options ([#19778](https://github.com/apache/superset/issues/19778)) ([d65b77e](https://github.com/apache/superset/commit/d65b77ec7dac4c2368fcaa1fe6e98db102966198))
- **deck.gl Multiple Layer Chart:** Add Contour and Heatmap Layer as options ([#25923](https://github.com/apache/superset/issues/25923)) ([64ba579](https://github.com/apache/superset/commit/64ba5797df92d0f8067ccd2b30ba6ff58e0bd791))
- deck.gl Scatterplot min/max radius ([#24363](https://github.com/apache/superset/issues/24363)) ([c728cdf](https://github.com/apache/superset/commit/c728cdf501ec292beb14a0982265052bf2274bec))
- **deck.gl:** multiple layers map size is shrunk ([#18939](https://github.com/apache/superset/issues/18939)) ([2cb3635](https://github.com/apache/superset/commit/2cb3635256ee8e91f0bac2f3091684673c04ff2b))
- **deck.gl:** update view state on property changes ([#17720](https://github.com/apache/superset/issues/17720)) ([#17826](https://github.com/apache/superset/issues/17826)) ([97d918b](https://github.com/apache/superset/commit/97d918b6927f572dca3b33c61b89c8b3ebdc4376))
- DeckGL legend layout ([#30140](https://github.com/apache/superset/issues/30140)) ([af066a4](https://github.com/apache/superset/commit/af066a46306f2f476aa2944b14df3de1faf1e96d))
- **deckgl:** deckgl unable to load map ([#17851](https://github.com/apache/superset/issues/17851)) ([52f5dcb](https://github.com/apache/superset/commit/52f5dcb58eec7b188f4387b8781dcda4252a5680))
- **explore:** Fix chart standalone URL for report/thumbnail generation ([#20673](https://github.com/apache/superset/issues/20673)) ([84d4302](https://github.com/apache/superset/commit/84d4302628d18aa19c13cc5322e68abbc690ea4d))
- **explore:** Prevent shared controls from checking feature flags outside React render ([#21315](https://github.com/apache/superset/issues/21315)) ([2285ebe](https://github.com/apache/superset/commit/2285ebe72ec4edded6d195052740b7f9f13d1f1b))
- weight tooltip issue ([#19397](https://github.com/apache/superset/issues/19397)) ([f6d550b](https://github.com/apache/superset/commit/f6d550b7fc3643350483850064e65dbd3d026dc4))
### Features
- Add Deck.gl Contour Layer ([#24154](https://github.com/apache/superset/issues/24154)) ([512fb9a](https://github.com/apache/superset/commit/512fb9a0bdd428b94b0c121158b8b15b7631e0fb))
- Add deck.gl Heatmap Visualization ([#23551](https://github.com/apache/superset/issues/23551)) ([fc8c537](https://github.com/apache/superset/commit/fc8c537118ce6c7b3a4624f88a31e2e7fb287327))
- Add line width unit control in deckgl Polygon and Path ([#24755](https://github.com/apache/superset/issues/24755)) ([d26ea98](https://github.com/apache/superset/commit/d26ea980acc7d2a20757efc360d810afe83d5c65))
- apply standardized form data to deckgl ([#20579](https://github.com/apache/superset/issues/20579)) ([290b89c](https://github.com/apache/superset/commit/290b89c7b4ae702c55f611bfac9cedb245ea8bd8))
- **deck.gl:** add color range for deck.gl 3D ([#19520](https://github.com/apache/superset/issues/19520)) ([c0a00fd](https://github.com/apache/superset/commit/c0a00fd302ec66fbe0ca766cf73978c99ba00d82))
- **deckgl-map:** use an arbitraty Mabpox style URL ([#26027](https://github.com/apache/superset/issues/26027)) ([#26031](https://github.com/apache/superset/issues/26031)) ([af58784](https://github.com/apache/superset/commit/af587840403d83a7da7fb0f57bc10ad2335d4eeb))
- **explore:** Frontend implementation of dataset creation from infobox ([#19855](https://github.com/apache/superset/issues/19855)) ([ba0c37d](https://github.com/apache/superset/commit/ba0c37d3df85b1af39404af1d578daeb0ff2d278))
- improve color consistency (save all labels) ([#19038](https://github.com/apache/superset/issues/19038)) ([dc57508](https://github.com/apache/superset/commit/dc575080d7e43d40b1734bb8f44fdc291cb95b11))
- **legacy-preset-chart-deckgl:** Add ,.1f and ,.2f value formats to deckgl charts ([#18945](https://github.com/apache/superset/issues/18945)) ([c56dc8e](https://github.com/apache/superset/commit/c56dc8eace6a71b45240d1bb6768d75661052a2e))
- make data tables support html ([#24368](https://github.com/apache/superset/issues/24368)) ([d2b0b8e](https://github.com/apache/superset/commit/d2b0b8eac52ad8b68639c6581a1ed174a593f564))
- **viz picker:** Remove some tags, refactor Recommended section ([#27708](https://github.com/apache/superset/issues/27708)) ([c314999](https://github.com/apache/superset/commit/c3149994ac0d4392e0462421b62cd0c034142082))
# [0.19.0](https://github.com/apache/superset/compare/v2021.41.0...v0.19.0) (2024-09-07)
### Bug Fixes
- **Dashboard:** Color inconsistency on refreshes and conflicts ([#27439](https://github.com/apache/superset/issues/27439)) ([313ee59](https://github.com/apache/superset/commit/313ee596f5435894f857d72be7269d5070c8c964))
- deck.gl Geojson path not visible ([#24428](https://github.com/apache/superset/issues/24428)) ([6bb930e](https://github.com/apache/superset/commit/6bb930ef4ed26ea381e7f8e889851aa7867ba0eb))
- deck.gl GeoJsonLayer Autozoom & fill/stroke options ([#19778](https://github.com/apache/superset/issues/19778)) ([d65b77e](https://github.com/apache/superset/commit/d65b77ec7dac4c2368fcaa1fe6e98db102966198))
- **deck.gl Multiple Layer Chart:** Add Contour and Heatmap Layer as options ([#25923](https://github.com/apache/superset/issues/25923)) ([64ba579](https://github.com/apache/superset/commit/64ba5797df92d0f8067ccd2b30ba6ff58e0bd791))
- deck.gl Scatterplot min/max radius ([#24363](https://github.com/apache/superset/issues/24363)) ([c728cdf](https://github.com/apache/superset/commit/c728cdf501ec292beb14a0982265052bf2274bec))
- **deck.gl:** multiple layers map size is shrunk ([#18939](https://github.com/apache/superset/issues/18939)) ([2cb3635](https://github.com/apache/superset/commit/2cb3635256ee8e91f0bac2f3091684673c04ff2b))
- **deck.gl:** update view state on property changes ([#17720](https://github.com/apache/superset/issues/17720)) ([#17826](https://github.com/apache/superset/issues/17826)) ([97d918b](https://github.com/apache/superset/commit/97d918b6927f572dca3b33c61b89c8b3ebdc4376))
- DeckGL legend layout ([#30140](https://github.com/apache/superset/issues/30140)) ([af066a4](https://github.com/apache/superset/commit/af066a46306f2f476aa2944b14df3de1faf1e96d))
- **deckgl:** deckgl unable to load map ([#17851](https://github.com/apache/superset/issues/17851)) ([52f5dcb](https://github.com/apache/superset/commit/52f5dcb58eec7b188f4387b8781dcda4252a5680))
- **explore:** Fix chart standalone URL for report/thumbnail generation ([#20673](https://github.com/apache/superset/issues/20673)) ([84d4302](https://github.com/apache/superset/commit/84d4302628d18aa19c13cc5322e68abbc690ea4d))
- **explore:** Prevent shared controls from checking feature flags outside React render ([#21315](https://github.com/apache/superset/issues/21315)) ([2285ebe](https://github.com/apache/superset/commit/2285ebe72ec4edded6d195052740b7f9f13d1f1b))
- weight tooltip issue ([#19397](https://github.com/apache/superset/issues/19397)) ([f6d550b](https://github.com/apache/superset/commit/f6d550b7fc3643350483850064e65dbd3d026dc4))
### Features
- Add Deck.gl Contour Layer ([#24154](https://github.com/apache/superset/issues/24154)) ([512fb9a](https://github.com/apache/superset/commit/512fb9a0bdd428b94b0c121158b8b15b7631e0fb))
- Add deck.gl Heatmap Visualization ([#23551](https://github.com/apache/superset/issues/23551)) ([fc8c537](https://github.com/apache/superset/commit/fc8c537118ce6c7b3a4624f88a31e2e7fb287327))
- Add line width unit control in deckgl Polygon and Path ([#24755](https://github.com/apache/superset/issues/24755)) ([d26ea98](https://github.com/apache/superset/commit/d26ea980acc7d2a20757efc360d810afe83d5c65))
- apply standardized form data to deckgl ([#20579](https://github.com/apache/superset/issues/20579)) ([290b89c](https://github.com/apache/superset/commit/290b89c7b4ae702c55f611bfac9cedb245ea8bd8))
- **deck.gl:** add color range for deck.gl 3D ([#19520](https://github.com/apache/superset/issues/19520)) ([c0a00fd](https://github.com/apache/superset/commit/c0a00fd302ec66fbe0ca766cf73978c99ba00d82))
- **deckgl-map:** use an arbitraty Mabpox style URL ([#26027](https://github.com/apache/superset/issues/26027)) ([#26031](https://github.com/apache/superset/issues/26031)) ([af58784](https://github.com/apache/superset/commit/af587840403d83a7da7fb0f57bc10ad2335d4eeb))
- **explore:** Frontend implementation of dataset creation from infobox ([#19855](https://github.com/apache/superset/issues/19855)) ([ba0c37d](https://github.com/apache/superset/commit/ba0c37d3df85b1af39404af1d578daeb0ff2d278))
- improve color consistency (save all labels) ([#19038](https://github.com/apache/superset/issues/19038)) ([dc57508](https://github.com/apache/superset/commit/dc575080d7e43d40b1734bb8f44fdc291cb95b11))
- **legacy-preset-chart-deckgl:** Add ,.1f and ,.2f value formats to deckgl charts ([#18945](https://github.com/apache/superset/issues/18945)) ([c56dc8e](https://github.com/apache/superset/commit/c56dc8eace6a71b45240d1bb6768d75661052a2e))
- make data tables support html ([#24368](https://github.com/apache/superset/issues/24368)) ([d2b0b8e](https://github.com/apache/superset/commit/d2b0b8eac52ad8b68639c6581a1ed174a593f564))
- **viz picker:** Remove some tags, refactor Recommended section ([#27708](https://github.com/apache/superset/issues/27708)) ([c314999](https://github.com/apache/superset/commit/c3149994ac0d4392e0462421b62cd0c034142082))

View File

@@ -1,57 +0,0 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
## @superset-ui/legacy-preset-chart-deckgl
[![Version](https://img.shields.io/npm/v/@superset-ui/legacy-preset-chart-deckgl.svg?style=flat)](https://img.shields.io/npm/v/@superset-ui/legacy-preset-chart-deckgl.svg?style=flat-square)
[![Libraries.io](https://img.shields.io/librariesio/release/npm/%40superset-ui%2Flegacy-preset-chart-deckgl?style=flat)](https://libraries.io/npm/@superset-ui%2Flegacy-preset-chart-deckgl)
This plugin provides `deck.gl` for Superset.
### Usage
Import the preset and register. This will register all the chart plugins under `deck.gl`.
```js
import { DeckGLChartPreset } from '@superset-ui/legacy-preset-chart-deckgl';
new DeckGLChartPreset().register();
```
or register charts one by one. Configure `key`, which can be any `string`, and register the plugin. This `key` will be used to lookup this chart throughout the app.
```js
import { ArcChartPlugin } from '@superset-ui/legacy-preset-chart-deckgl';
new ArcChartPlugin().configure({ key: 'deck_arc' }).register();
```
Then use it via `SuperChart`. See [storybook](https://apache-superset.github.io/superset-ui-plugins-deckgl) for more details.
```js
<SuperChart
chartType="deck_arc"
width={600}
height={600}
formData={...}
queriesData={[{
data: {...},
}]}
/>
```

View File

@@ -1,71 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
declare module 'deck.gl' {
import { Layer, LayerProps } from '@deck.gl/core';
interface HeatmapLayerProps<T extends object = any> extends LayerProps<T> {
id?: string;
data?: T[];
getPosition?: (d: T) => number[] | null | undefined;
getWeight?: (d: T) => number | null | undefined;
radiusPixels?: number;
colorRange?: number[][];
threshold?: number;
intensity?: number;
aggregation?: string;
}
interface ContourLayerProps<T extends object = any> extends LayerProps<T> {
id?: string;
data?: T[];
getPosition?: (d: T) => number[] | null | undefined;
getWeight?: (d: T) => number | null | undefined;
contours: {
color?: ColorType | undefined;
lowerThreshold?: any | undefined;
upperThreshold?: any | undefined;
strokeWidth?: any | undefined;
zIndex?: any | undefined;
};
cellSize: number;
colorRange?: number[][];
intensity?: number;
aggregation?: string;
}
export class HeatmapLayer<T extends object = any> extends Layer<
T,
HeatmapLayerProps<T>
> {
constructor(props: HeatmapLayerProps<T>);
}
export class ContourLayer<T extends object = any> extends Layer<
T,
ContourLayerProps<T>
> {
constructor(props: ContourLayerProps<T>);
}
}
declare module '*.png' {
const value: any;
export default value;
}

View File

@@ -273,18 +273,28 @@ function Echart(
);
const notMerge = !isDashboardRefreshing;
if (!notMerge) {
chartRef.current?.dispatchAction({ type: 'hideTip' });
}
chartRef.current?.dispatchAction({ type: 'hideTip' });
chartRef.current?.setOption(themedEchartOptions, {
notMerge,
replaceMerge: notMerge ? undefined : ['series'],
lazyUpdate: isDashboardRefreshing,
// lazyUpdate defers render, causing tooltip crashes on stale shapes (#39247)
lazyUpdate: false,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- isDashboardRefreshing intentionally excluded to prevent extra setOption calls
}, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme, vizType]);
// Clear tooltip on refresh start to avoid stale content (#39247)
useEffect(() => {
if (didMount && isDashboardRefreshing && chartRef.current) {
chartRef.current.dispatchAction({ type: 'hideTip' });
chartRef.current.dispatchAction({
type: 'updateAxisPointer',
currTrigger: 'leave',
});
}
}, [didMount, isDashboardRefreshing]);
useEffect(() => () => chartRef.current?.dispose(), []);
// highlighting

View File

@@ -29,7 +29,8 @@ import { Refs } from '../types';
export function getDefaultTooltip(refs: Refs) {
return {
appendToBody: true,
appendToBody:
typeof document !== 'undefined' ? !document.fullscreenElement : true,
borderColor: 'transparent',
// CSS hack applied on this class to resolve https://github.com/apache/superset/issues/30058
className: 'echarts-tooltip',

View File

@@ -16,67 +16,66 @@
* specific language governing permissions and limitations
* under the License.
*/
import { QueryFormData } from '@superset-ui/core';
import { isPostProcessingRank, QueryFormData } from '@superset-ui/core';
import buildQuery from '../../src/Heatmap/buildQuery';
describe('Heatmap buildQuery - Rank Operation for Normalized Field', () => {
const baseFormData = {
datasource: '5__table',
granularity_sqla: 'ds',
metric: 'count',
x_axis: 'category',
groupby: ['region'],
viz_type: 'heatmap',
} as QueryFormData;
const baseFormData = {
datasource: '5__table',
granularity_sqla: 'ds',
metric: 'count',
x_axis: 'category',
groupby: ['region'],
viz_type: 'heatmap',
} as QueryFormData;
test('should ALWAYS include rank operation when normalized=true', () => {
const formData = {
...baseFormData,
normalized: true,
};
const getQuery = (formData: QueryFormData) => buildQuery(formData).queries[0];
const getRankOperation = (formData: QueryFormData) =>
getQuery(formData).post_processing?.find(isPostProcessingRank);
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
const rankOperation = query.post_processing?.find(
op => op?.operation === 'rank',
);
expect(rankOperation).toBeDefined();
expect(rankOperation?.operation).toBe('rank');
test('adds X axis orderby when sorting alphabetically ascending', () => {
const query = getQuery({
...baseFormData,
sort_x_axis: 'alpha_asc',
});
test('should ALWAYS include rank operation when normalized=false', () => {
const formData = {
...baseFormData,
normalized: false,
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
const rankOperation = query.post_processing?.find(
op => op?.operation === 'rank',
);
expect(rankOperation).toBeDefined();
expect(rankOperation?.operation).toBe('rank');
});
test('should ALWAYS include rank operation when normalized is undefined', () => {
const formData = {
...baseFormData,
// normalized not set
};
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
const rankOperation = query.post_processing?.find(
op => op?.operation === 'rank',
);
expect(rankOperation).toBeDefined();
expect(rankOperation?.operation).toBe('rank');
});
expect(query.orderby).toEqual([['category', true]]);
});
test('adds Y axis orderby when sorting alphabetically descending', () => {
const query = getQuery({
...baseFormData,
sort_y_axis: 'alpha_desc',
});
expect(query.orderby).toEqual([['region', false]]);
});
test('should ALWAYS include rank operation when normalized=true', () => {
const rankOperation = getRankOperation({
...baseFormData,
normalized: true,
});
expect(rankOperation).toBeDefined();
expect(rankOperation?.operation).toBe('rank');
});
test('should ALWAYS include rank operation when normalized=false', () => {
const rankOperation = getRankOperation({
...baseFormData,
normalized: false,
});
expect(rankOperation).toBeDefined();
expect(rankOperation?.operation).toBe('rank');
});
test('should ALWAYS include rank operation when normalized is undefined', () => {
const rankOperation = getRankOperation({
...baseFormData,
// normalized not set
});
expect(rankOperation).toBeDefined();
expect(rankOperation?.operation).toBe('rank');
});

View File

@@ -1,7 +1,7 @@
{
"name": "@superset-ui/legacy-plugin-chart-map-box",
"version": "0.20.3",
"description": "Superset Legacy Chart - MapBox",
"name": "@superset-ui/plugin-chart-point-cluster-map",
"version": "1.0.0",
"description": "Superset Chart Plugin - Point Cluster Map",
"keywords": [
"superset"
],
@@ -12,7 +12,7 @@
"repository": {
"type": "git",
"url": "https://github.com/apache/superset.git",
"directory": "superset-frontend/plugins/legacy-plugin-chart-map-box"
"directory": "superset-frontend/plugins/plugin-chart-point-cluster-map"
},
"license": "Apache-2.0",
"author": "Superset",
@@ -27,16 +27,17 @@
],
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"prop-types": "^15.8.1",
"react-map-gl": "^6.1.19",
"mapbox-gl": "^3.0.0",
"maplibre-gl": "^5.0.0",
"react-map-gl": "^8.0.0",
"supercluster": "^8.0.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"mapbox-gl": "*",
"react": "^17.0.2"
"react": "^17.0.2 || ^19.0.0",
"react-dom": "^17.0.2 || ^19.0.0"
},
"publishConfig": {
"access": "public"

View File

@@ -16,6 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
.mapbox .slice_container div {
.maplibre .slice_container div {
padding-top: 0px;
}

View File

@@ -0,0 +1,216 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo, useCallback, useEffect, useState } from 'react';
import { Map as MapLibreMap } from 'react-map-gl/maplibre';
import { Map as MapboxMap } from 'react-map-gl/mapbox';
import { WebMercatorViewport } from '@math.gl/web-mercator';
import { useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import ScatterPlotOverlay from './components/ScatterPlotOverlay';
import { getMapboxApiKey } from './utils/mapbox';
import 'maplibre-gl/dist/maplibre-gl.css';
import './MapLibre.css';
const DEFAULT_MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
export const DEFAULT_MAX_ZOOM = 16;
export const DEFAULT_POINT_RADIUS = 60;
interface Viewport {
longitude: number;
latitude: number;
zoom: number;
}
interface Clusterer {
getClusters(bbox: number[], zoom: number): GeoJSONLocation[];
}
interface GeoJSONLocation {
geometry: {
coordinates: [number, number];
};
properties: Record<string, number | string | boolean | null | undefined>;
}
interface MapLibreProps {
width?: number;
height?: number;
aggregatorName?: string;
clusterer: Clusterer; // Required - used for getClusters()
globalOpacity?: number;
hasCustomMetric?: boolean;
mapProvider?: string;
mapStyle?: string;
onViewportChange?: (viewport: Viewport) => void;
pointRadius?: number;
pointRadiusUnit?: string;
renderWhileDragging?: boolean;
rgb?: (string | number)[];
bounds?: [[number, number], [number, number]]; // May be undefined for empty datasets
viewportLongitude?: number;
viewportLatitude?: number;
viewportZoom?: number;
}
function MapLibre({
width = 400,
height = 400,
aggregatorName,
clusterer,
globalOpacity = 1,
hasCustomMetric,
mapProvider,
mapStyle,
onViewportChange,
pointRadius = DEFAULT_POINT_RADIUS,
pointRadiusUnit = 'Pixels',
renderWhileDragging = true,
rgb,
bounds,
viewportLongitude,
viewportLatitude,
viewportZoom,
}: MapLibreProps) {
const computeFitBounds = useCallback((): Viewport => {
if (bounds && bounds[0] && bounds[1]) {
const mercator = new WebMercatorViewport({ width, height }).fitBounds(
bounds,
);
return {
latitude: mercator.latitude,
longitude: mercator.longitude,
zoom: mercator.zoom,
};
}
return { latitude: 0, longitude: 0, zoom: 1 };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const mergeViewportWithProps = useCallback(
(fitBounds: Viewport, base: Viewport = fitBounds): Viewport => ({
...base,
longitude: viewportLongitude ?? fitBounds.longitude,
latitude: viewportLatitude ?? fitBounds.latitude,
zoom: viewportZoom ?? fitBounds.zoom,
}),
[viewportLongitude, viewportLatitude, viewportZoom],
);
const [viewport, setViewport] = useState<Viewport>(() =>
mergeViewportWithProps(computeFitBounds()),
);
useEffect(() => {
const fitBounds = computeFitBounds();
const next = mergeViewportWithProps(fitBounds, viewport);
if (
next.longitude !== viewport.longitude ||
next.latitude !== viewport.latitude ||
next.zoom !== viewport.zoom
) {
setViewport(next);
}
// Only re-run when the viewport-override props change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewportLongitude, viewportLatitude, viewportZoom]);
const handleMove = useCallback(
(evt: {
viewState: { longitude: number; latitude: number; zoom: number };
}) => {
const { longitude, latitude, zoom } = evt.viewState;
const newViewport = { longitude, latitude, zoom };
setViewport(newViewport);
onViewportChange?.(newViewport);
},
[onViewportChange],
);
// add this variable to widen the visible area
const offsetHorizontal = (width * 0.5) / 100;
const offsetVertical = (height * 0.5) / 100;
const bbox =
bounds && bounds[0] && bounds[1]
? [
bounds[0][0] - offsetHorizontal,
bounds[0][1] - offsetVertical,
bounds[1][0] + offsetHorizontal,
bounds[1][1] + offsetVertical,
]
: [-180, -90, 180, 90];
const clusters = clusterer.getClusters(bbox, Math.round(viewport.zoom));
const theme = useTheme();
const resolvedMapStyle = mapStyle || DEFAULT_MAP_STYLE;
const mapboxApiKey = mapProvider === 'mapbox' ? getMapboxApiKey() : '';
if (mapProvider === 'mapbox' && !mapboxApiKey) {
return (
<div
style={{
width,
height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
textAlign: 'center',
color: theme.colorTextSecondary,
}}
>
{t('Mapbox requires a MAPBOX_API_KEY to be configured on the server.')}
</div>
);
}
const MapComponent = mapProvider === 'mapbox' ? MapboxMap : MapLibreMap;
const mapboxProps =
mapProvider === 'mapbox' ? { mapboxAccessToken: mapboxApiKey } : {};
return (
<MapComponent
{...viewport}
{...mapboxProps}
style={{ width, height }}
mapStyle={resolvedMapStyle}
onMove={handleMove}
>
<ScatterPlotOverlay
locations={clusters}
dotRadius={pointRadius}
pointRadiusUnit={pointRadiusUnit}
rgb={rgb}
globalOpacity={globalOpacity}
compositeOperation="screen"
renderWhileDragging={renderWhileDragging}
aggregation={hasCustomMetric ? aggregatorName : undefined}
zoom={viewport.zoom}
lngLatAccessor={(location: GeoJSONLocation) => {
const { coordinates } = location.geometry;
return [coordinates[0], coordinates[1]];
}}
/>
</MapComponent>
);
}
export default memo(MapLibre);

View File

@@ -0,0 +1,90 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
buildQueryContext,
ensureIsArray,
QueryFormColumn,
QueryObject,
QueryObjectFilterClause,
SqlaFormData,
} from '@superset-ui/core';
export interface MapLibreFormData extends SqlaFormData {
all_columns_x?: string;
all_columns_y?: string;
map_label?: string[];
point_radius?: string;
clustering_radius?: string;
pandas_aggfunc?: string;
global_opacity?: number;
maplibre_style?: string;
mapbox_style?: string;
map_color?: string;
render_while_dragging?: boolean;
point_radius_unit?: string;
}
export default function buildQuery(formData: MapLibreFormData) {
const { all_columns_x, all_columns_y, map_label, point_radius } = formData;
if (!all_columns_x || !all_columns_y) {
throw new Error('Longitude and latitude columns are required');
}
return buildQueryContext(formData, (baseQueryObject: QueryObject) => {
const columns: QueryFormColumn[] = [
...ensureIsArray(baseQueryObject.columns || []),
all_columns_x,
all_columns_y,
];
// Add label column if specified and not 'count'
const hasCustomMetric =
map_label && map_label.length > 0 && map_label[0] !== 'count';
if (hasCustomMetric) {
columns.push(map_label[0]);
}
// Add point radius column if not "Auto"
if (point_radius && point_radius !== 'Auto') {
columns.push(point_radius);
}
// Add null filters for lon/lat
const filters: QueryObjectFilterClause[] = ensureIsArray(
baseQueryObject.filters || [],
);
filters.push(
{ col: all_columns_x, op: 'IS NOT NULL' },
{ col: all_columns_y, op: 'IS NOT NULL' },
);
// Deduplicate columns
const uniqueColumns = [...new Set(columns)];
return [
{
...baseQueryObject,
columns: uniqueColumns,
filters,
is_timeseries: false,
},
];
});
}

View File

@@ -0,0 +1,121 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useRef } from 'react';
import { useMap as useMapLibre } from 'react-map-gl/maplibre';
import { useMap as useMapbox } from 'react-map-gl/mapbox';
export interface RedrawParams {
width: number;
height: number;
ctx: CanvasRenderingContext2D;
isDragging: boolean;
project: (lngLat: [number, number]) => [number, number];
}
interface CanvasOverlayProps {
redraw: (params: RedrawParams) => void;
}
export default function CanvasOverlay({ redraw }: CanvasOverlayProps) {
const mapLibreContext = useMapLibre();
const mapboxContext = useMapbox();
const mapRef = (mapLibreContext.current ?? mapboxContext.current) as any;
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDraggingRef = useRef(false);
const project = useCallback(
(lngLat: [number, number]): [number, number] => {
if (!mapRef) return [0, 0];
const map = mapRef.getMap();
const point = map.project(lngLat);
return [point.x, point.y];
},
[mapRef],
);
const performRedraw = useCallback(() => {
const canvas = canvasRef.current;
const map = mapRef?.getMap();
if (!canvas || !map) return;
const container = map.getContainer();
const dpr = window.devicePixelRatio || 1;
const width = container.clientWidth;
const height = container.clientHeight;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
redraw({
width,
height,
ctx,
isDragging: isDraggingRef.current,
project,
});
}, [mapRef, redraw, project]);
useEffect(() => {
const map = mapRef?.getMap();
if (!map) return undefined;
const onMove = () => performRedraw();
const onDragStart = () => {
isDraggingRef.current = true;
};
const onDragEnd = () => {
isDraggingRef.current = false;
performRedraw();
};
const onResize = () => performRedraw();
map.on('move', onMove);
map.on('dragstart', onDragStart);
map.on('dragend', onDragEnd);
map.on('resize', onResize);
performRedraw();
return () => {
map.off('move', onMove);
map.off('dragstart', onDragStart);
map.off('dragend', onDragEnd);
map.off('resize', onResize);
};
}, [mapRef, performRedraw]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
pointerEvents: 'none',
}}
/>
);
}

View File

@@ -0,0 +1,400 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo, useCallback } from 'react';
import CanvasOverlay, { type RedrawParams } from './CanvasOverlay';
import { kmToPixels, MILES_PER_KM } from '../utils/geo';
import roundDecimal from '../utils/roundDecimal';
import luminanceFromRGB from '../utils/luminanceFromRGB';
// Shared radius bounds keep cluster and point sizing in sync.
export const MIN_CLUSTER_RADIUS_RATIO = 1 / 6;
export const MAX_POINT_RADIUS_RATIO = 1 / 3;
interface GeoJSONLocation {
geometry: {
coordinates: [number, number];
};
properties: Record<string, number | string | boolean | null | undefined>;
}
interface DrawTextOptions {
fontHeight?: number;
label?: string | number;
radius?: number;
rgb?: (string | number)[];
shadow?: boolean;
}
interface ScatterPlotOverlayProps {
aggregation?: string;
compositeOperation?: string;
dotRadius?: number;
globalOpacity?: number;
lngLatAccessor?: (location: GeoJSONLocation) => [number, number];
locations: GeoJSONLocation[];
pointRadiusUnit?: string;
renderWhileDragging?: boolean;
rgb?: (string | number)[];
zoom?: number;
}
const IS_DARK_THRESHOLD = 110;
const defaultLngLatAccessor = (location: GeoJSONLocation): [number, number] => [
location.geometry.coordinates[0],
location.geometry.coordinates[1],
];
const computeClusterLabel = (
properties: Record<string, number | string | boolean | null | undefined>,
aggregation: string | undefined,
): number | string => {
const count = properties.point_count as number;
if (!aggregation) {
return count;
}
if (aggregation === 'sum' || aggregation === 'min' || aggregation === 'max') {
return properties[aggregation] as number;
}
const { sum } = properties as { sum: number };
const mean = sum / count;
if (aggregation === 'mean') {
return Math.round(100 * mean) / 100;
}
const { squaredSum } = properties as { squaredSum: number };
const variance = squaredSum / count - (sum / count) ** 2;
if (aggregation === 'var') {
return Math.round(100 * variance) / 100;
}
if (aggregation === 'std' || aggregation === 'stdev') {
return Math.round(100 * Math.sqrt(variance)) / 100;
}
// fallback to point_count
return count;
};
function drawText(
ctx: CanvasRenderingContext2D,
pixel: [number, number],
compositeOperation: string,
options: DrawTextOptions = {},
) {
const {
fontHeight = 0,
label = '',
radius = 0,
rgb = [0, 0, 0],
shadow = false,
} = options;
const maxWidth = radius * 1.8;
const luminance = luminanceFromRGB(
rgb[1] as number,
rgb[2] as number,
rgb[3] as number,
);
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = luminance <= IS_DARK_THRESHOLD ? 'white' : 'black';
ctx.font = `${fontHeight}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (shadow) {
ctx.shadowBlur = 15;
ctx.shadowColor = luminance <= IS_DARK_THRESHOLD ? 'black' : '';
}
const textWidth = ctx.measureText(String(label)).width;
if (textWidth > maxWidth) {
const scale = fontHeight / textWidth;
ctx.font = `${scale * maxWidth}px sans-serif`;
}
ctx.fillText(String(label), pixel[0], pixel[1]);
ctx.globalCompositeOperation = compositeOperation as GlobalCompositeOperation;
ctx.shadowBlur = 0;
ctx.shadowColor = '';
}
function ScatterPlotOverlay({
aggregation,
compositeOperation = 'source-over',
dotRadius = 4,
globalOpacity = 1,
lngLatAccessor = defaultLngLatAccessor,
locations,
pointRadiusUnit,
renderWhileDragging = true,
rgb,
zoom,
}: ScatterPlotOverlayProps) {
const redraw = useCallback(
({ width, height, ctx, isDragging, project }: RedrawParams) => {
const radius = dotRadius;
const clusterLabelMap: (number | string)[] = [];
locations.forEach((location, i) => {
if (location.properties.cluster) {
clusterLabelMap[i] = computeClusterLabel(
location.properties,
aggregation,
);
}
});
const finiteClusterLabels = clusterLabelMap
.map(value => Number(value))
.filter(value => Number.isFinite(value));
const safeMaxAbsLabel =
finiteClusterLabels.length > 0
? Math.max(
Math.max(...finiteClusterLabels.map(value => Math.abs(value))),
1,
)
: 1;
// Calculate min/max radius values for Pixels mode scaling
let minRadiusValue = Infinity;
let maxRadiusValue = -Infinity;
if (pointRadiusUnit === 'Pixels') {
locations.forEach(location => {
if (
!location.properties.cluster &&
location.properties.radius != null
) {
const radiusValueRaw = location.properties.radius;
const radiusValue =
typeof radiusValueRaw === 'string' && radiusValueRaw.trim() === ''
? null
: Number(radiusValueRaw);
if (radiusValue != null && Number.isFinite(radiusValue)) {
minRadiusValue = Math.min(minRadiusValue, radiusValue);
maxRadiusValue = Math.max(maxRadiusValue, radiusValue);
}
}
});
}
ctx.clearRect(0, 0, width, height);
ctx.globalCompositeOperation =
compositeOperation as GlobalCompositeOperation;
if ((renderWhileDragging || !isDragging) && locations) {
locations.forEach((location: GeoJSONLocation, i: number) => {
const pixel = project(lngLatAccessor(location));
const pixelRounded: [number, number] = [
roundDecimal(pixel[0], 1),
roundDecimal(pixel[1], 1),
];
if (
pixelRounded[0] + radius >= 0 &&
pixelRounded[0] - radius < width &&
pixelRounded[1] + radius >= 0 &&
pixelRounded[1] - radius < height
) {
ctx.beginPath();
if (location.properties.cluster) {
const clusterLabel = clusterLabelMap[i];
const numericLabel = Number(clusterLabel);
const safeNumericLabel = Number.isFinite(numericLabel)
? numericLabel
: 0;
const minClusterRadius =
pointRadiusUnit === 'Pixels'
? radius * MAX_POINT_RADIUS_RATIO
: radius * MIN_CLUSTER_RADIUS_RATIO;
const ratio = Math.abs(safeNumericLabel) / safeMaxAbsLabel;
const scaledRadius = roundDecimal(
minClusterRadius + ratio ** 0.5 * (radius - minClusterRadius),
1,
);
const fontHeight = roundDecimal(scaledRadius * 0.5, 1);
const [x, y] = pixelRounded;
const gradient = ctx.createRadialGradient(
x,
y,
scaledRadius,
x,
y,
0,
);
gradient.addColorStop(
1,
`rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${0.8 * globalOpacity})`,
);
gradient.addColorStop(
0,
`rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, 0)`,
);
ctx.arc(
pixelRounded[0],
pixelRounded[1],
scaledRadius,
0,
Math.PI * 2,
);
ctx.fillStyle = gradient;
ctx.fill();
if (Number.isFinite(safeNumericLabel)) {
let label: string | number = clusterLabel;
const absLabel = Math.abs(safeNumericLabel);
const sign = safeNumericLabel < 0 ? '-' : '';
if (absLabel >= 10000) {
label = `${sign}${Math.round(absLabel / 1000)}k`;
} else if (absLabel >= 1000) {
label = `${sign}${Math.round(absLabel / 100) / 10}k`;
}
drawText(ctx, pixelRounded, compositeOperation, {
fontHeight,
label,
radius: scaledRadius,
rgb,
shadow: true,
});
}
} else {
const defaultRadius = radius * MIN_CLUSTER_RADIUS_RATIO;
const rawRadius = location.properties.radius;
const numericRadiusProperty =
rawRadius != null &&
!(typeof rawRadius === 'string' && rawRadius.trim() === '')
? Number(rawRadius)
: null;
const radiusProperty =
numericRadiusProperty != null &&
Number.isFinite(numericRadiusProperty)
? numericRadiusProperty
: null;
const pointMetric = location.properties.metric ?? null;
let pointRadius: number = radiusProperty ?? defaultRadius;
let pointLabel: string | number | undefined;
if (radiusProperty != null) {
const pointLatitude = lngLatAccessor(location)[1];
if (pointRadiusUnit === 'Kilometers') {
pointLabel = `${roundDecimal(pointRadius, 2)}km`;
pointRadius = kmToPixels(
pointRadius,
pointLatitude,
zoom ?? 0,
);
} else if (pointRadiusUnit === 'Miles') {
pointLabel = `${roundDecimal(pointRadius, 2)}mi`;
pointRadius = kmToPixels(
pointRadius * MILES_PER_KM,
pointLatitude,
zoom ?? 0,
);
} else if (pointRadiusUnit === 'Pixels') {
const MIN_POINT_RADIUS = radius * MIN_CLUSTER_RADIUS_RATIO;
const MAX_POINT_RADIUS = radius * MAX_POINT_RADIUS_RATIO;
if (
Number.isFinite(minRadiusValue) &&
Number.isFinite(maxRadiusValue) &&
maxRadiusValue > minRadiusValue
) {
const numericPointRadius = Number(pointRadius);
if (!Number.isFinite(numericPointRadius)) {
pointRadius = MIN_POINT_RADIUS;
} else {
const normalizedValueRaw =
(numericPointRadius - minRadiusValue) /
(maxRadiusValue - minRadiusValue);
const normalizedValue = Math.max(
0,
Math.min(1, normalizedValueRaw),
);
pointRadius =
MIN_POINT_RADIUS +
normalizedValue * (MAX_POINT_RADIUS - MIN_POINT_RADIUS);
}
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
} else if (
Number.isFinite(minRadiusValue) &&
minRadiusValue === maxRadiusValue
) {
pointRadius = (MIN_POINT_RADIUS + MAX_POINT_RADIUS) / 2;
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
} else {
pointRadius = Math.max(
MIN_POINT_RADIUS,
Math.min(pointRadius, MAX_POINT_RADIUS),
);
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
}
}
}
if (pointMetric !== null) {
const numericMetric = parseFloat(String(pointMetric));
pointLabel = Number.isFinite(numericMetric)
? roundDecimal(numericMetric, 2)
: String(pointMetric);
}
if (!pointRadius) {
pointRadius = defaultRadius;
}
ctx.arc(
pixelRounded[0],
pixelRounded[1],
roundDecimal(pointRadius, 1),
0,
Math.PI * 2,
);
ctx.fillStyle = `rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${globalOpacity})`;
ctx.fill();
if (pointLabel !== undefined) {
drawText(ctx, pixelRounded, compositeOperation, {
fontHeight: roundDecimal(pointRadius, 1),
label: pointLabel,
radius: pointRadius,
rgb,
shadow: false,
});
}
}
}
});
}
},
[
aggregation,
compositeOperation,
dotRadius,
globalOpacity,
lngLatAccessor,
locations,
pointRadiusUnit,
renderWhileDragging,
rgb,
zoom,
],
);
return <CanvasOverlay redraw={redraw} />;
}
export default memo(ScatterPlotOverlay);

View File

@@ -17,7 +17,6 @@
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { validateMapboxStylesUrl } from '@superset-ui/core';
import {
columnChoices,
ControlPanelConfig,
@@ -29,12 +28,12 @@ import {
const columnsConfig = sharedControls.entity;
const colorChoices = [
['rgb(0, 139, 139)', t('Dark Cyan')],
['rgb(128, 0, 128)', t('Purple')],
['rgb(255, 215, 0)', t('Gold')],
['rgb(69, 69, 69)', t('Dim Gray')],
['rgb(220, 20, 60)', t('Crimson')],
['rgb(34, 139, 34)', t('Forest Green')],
['#008b8b', t('Dark Cyan')],
['#800080', t('Purple')],
['#ffd700', t('Gold')],
['#454545', t('Dim Gray')],
['#dc143c', t('Crimson')],
['#228b22', t('Forest Green')],
];
const config: ControlPanelConfig = {
@@ -110,7 +109,7 @@ const config: ControlPanelConfig = {
'Either a numerical column or `Auto`, which scales the point based ' +
'on the largest cluster',
),
mapStateToProps: state => {
mapStateToProps: (state: any) => {
const datasourceChoices = columnChoices(state.datasource);
const choices: [string, string][] = [['Auto', t('Auto')]];
return {
@@ -145,7 +144,7 @@ const config: ControlPanelConfig = {
controlSetRows: [
[
{
name: 'mapbox_label',
name: 'map_label',
config: {
type: 'SelectControl',
multi: true,
@@ -157,7 +156,7 @@ const config: ControlPanelConfig = {
'Non-numerical columns will be used to label points. ' +
'Leave empty to get a count of points in each cluster.',
),
mapStateToProps: state => ({
mapStateToProps: (state: any) => ({
choices: columnChoices(state.datasource),
}),
},
@@ -189,21 +188,66 @@ const config: ControlPanelConfig = {
],
},
{
label: t('Visual Tweaks'),
label: t('Map'),
tabOverride: 'customize',
expanded: true,
controlSetRows: [
[
{
name: 'render_while_dragging',
name: 'map_renderer',
config: {
type: 'CheckboxControl',
label: t('Live render'),
default: true,
type: 'SelectControl',
label: t('Map Renderer'),
clearable: false,
renderTrigger: true,
choices: [
['maplibre', t('MapLibre (open-source)')],
['mapbox', t('Mapbox (API key required)')],
],
default: 'maplibre',
description: t(
'Points and clusters will update as the viewport is being changed',
'MapLibre is open-source and requires no API key. Mapbox requires MAPBOX_API_KEY to be configured on the server.',
),
},
},
],
[
{
name: 'maplibre_style',
config: {
type: 'SelectControl',
label: t('Map Style'),
clearable: false,
renderTrigger: true,
freeForm: true,
choices: [
[
'https://tiles.openfreemap.org/styles/liberty',
t('Liberty (OpenFreeMap)'),
],
[
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
t('Light (Carto)'),
],
[
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
t('Dark (Carto)'),
],
[
'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
t('Streets (Carto)'),
],
],
default: 'https://tiles.openfreemap.org/styles/liberty',
description: t(
'Base layer map style. See MapLibre documentation: %s',
'https://maplibre.org/maplibre-style-spec/',
),
visibility: ({ controls }: any) =>
controls?.map_renderer?.value !== 'mapbox',
},
},
],
[
{
name: 'mapbox_style',
@@ -213,22 +257,42 @@ const config: ControlPanelConfig = {
clearable: false,
renderTrigger: true,
freeForm: true,
validators: [validateMapboxStylesUrl],
choices: [
['mapbox://styles/mapbox/streets-v9', t('Streets')],
['mapbox://styles/mapbox/dark-v9', t('Dark')],
['mapbox://styles/mapbox/light-v9', t('Light')],
['mapbox://styles/mapbox/streets-v12', t('Streets')],
['mapbox://styles/mapbox/outdoors-v12', t('Outdoors')],
['mapbox://styles/mapbox/light-v11', t('Light')],
['mapbox://styles/mapbox/dark-v11', t('Dark')],
['mapbox://styles/mapbox/satellite-v9', t('Satellite')],
[
'mapbox://styles/mapbox/satellite-streets-v9',
'mapbox://styles/mapbox/satellite-streets-v12',
t('Satellite Streets'),
],
['mapbox://styles/mapbox/satellite-v9', t('Satellite')],
['mapbox://styles/mapbox/outdoors-v9', t('Outdoors')],
],
default: 'mapbox://styles/mapbox/light-v9',
default: 'mapbox://styles/mapbox/light-v11',
description: t(
'Base layer map style. See Mapbox documentation: %s',
'https://docs.mapbox.com/help/glossary/style-url/',
'Base layer map style. Accepts a Mapbox style URL (mapbox://styles/...).',
),
visibility: ({ controls }: any) =>
controls?.map_renderer?.value === 'mapbox',
},
},
],
],
},
{
label: t('Visual Tweaks'),
tabOverride: 'customize',
controlSetRows: [
[
{
name: 'render_while_dragging',
config: {
type: 'CheckboxControl',
label: t('Live render'),
renderTrigger: true,
default: true,
description: t(
'Points and clusters will update as the viewport is being changed',
),
},
},
@@ -239,9 +303,9 @@ const config: ControlPanelConfig = {
config: {
type: 'TextControl',
label: t('Opacity'),
renderTrigger: true,
default: 1,
isFloat: true,
renderTrigger: true,
description: t(
'Opacity of all clusters, points, and labels. Between 0 and 1.',
),
@@ -250,10 +314,11 @@ const config: ControlPanelConfig = {
],
[
{
name: 'mapbox_color',
name: 'map_color',
config: {
type: 'SelectControl',
freeForm: true,
renderTrigger: true,
label: t('RGB Color'),
default: colorChoices[0][0],
choices: colorChoices,
@@ -278,7 +343,6 @@ const config: ControlPanelConfig = {
isFloat: true,
description: t('Longitude of default viewport'),
places: 8,
// Viewport longitude changes shouldn't prompt user to re-run query
dontRefreshOnChange: true,
},
},
@@ -292,7 +356,6 @@ const config: ControlPanelConfig = {
isFloat: true,
description: t('Latitude of default viewport'),
places: 8,
// Viewport latitude changes shouldn't prompt user to re-run query
dontRefreshOnChange: true,
},
},
@@ -308,7 +371,6 @@ const config: ControlPanelConfig = {
default: '',
description: t('Zoom level of the map'),
places: 8,
// Viewport zoom shouldn't prompt user to re-run query
dontRefreshOnChange: true,
},
},
@@ -325,7 +387,7 @@ const config: ControlPanelConfig = {
),
},
},
formDataOverrides: formData => ({
formDataOverrides: (formData: any) => ({
...formData,
groupby: getStandardizedControls().popAllColumns(),
}),

View File

@@ -28,31 +28,30 @@ import controlPanel from './controlPanel';
const metadata = new ChartMetadata({
category: t('Map'),
credits: ['https://www.mapbox.com/mapbox-gl-js/api/'],
credits: ['https://maplibre.org/'],
description: '',
exampleGallery: [
{ url: example1, urlDark: example1Dark, caption: t('Light mode') },
{ url: example2, urlDark: example2Dark, caption: t('Dark mode') },
],
name: t('MapBox'),
name: t('Point Cluster Map'),
tags: [
t('Business'),
t('Intensity'),
t('Legacy'),
t('Density'),
t('Scatter'),
t('Transformable'),
],
thumbnail,
thumbnailDark,
useLegacyApi: true,
});
export default class MapBoxChartPlugin extends ChartPlugin {
export default class ScatterMapChartPlugin extends ChartPlugin {
constructor() {
super({
loadChart: () => import('./MapBox'),
loadChart: () => import('./MapLibre'),
loadTransformProps: () => import('./transformProps'),
loadBuildQuery: () => import('./buildQuery'),
metadata,
controlPanel,
});

View File

@@ -0,0 +1,176 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable sort-keys, no-magic-numbers */
import { SuperChart } from '@superset-ui/core';
import { useTheme } from '@apache-superset/core/theme';
import ScatterMapChartPlugin from '@superset-ui/plugin-chart-point-cluster-map';
import { withResizableChartDemo } from '@storybook-shared';
import { generateData } from './data';
new ScatterMapChartPlugin().configure({ key: 'point_cluster_map' }).register();
export default {
title: 'Chart Plugins/plugin-chart-point-cluster-map',
decorators: [withResizableChartDemo],
args: {
clusteringRadius: 60,
globalOpacity: 1,
pointRadius: 'Auto',
renderWhileDragging: true,
mapRenderer: 'maplibre',
},
argTypes: {
clusteringRadius: {
control: { type: 'range', min: 0, max: 200, step: 10 },
description: 'Radius in pixels for clustering points',
},
globalOpacity: {
control: { type: 'range', min: 0, max: 1, step: 0.1 },
description: 'Opacity of map markers',
},
pointRadius: {
control: 'select',
options: ['Auto', 1, 2, 5, 10, 20, 50],
description: 'Size of point markers',
},
renderWhileDragging: {
control: 'boolean',
description: 'Render markers while dragging the map',
},
mapRenderer: {
control: 'select',
options: ['maplibre', 'mapbox'],
description:
'Map renderer. MapLibre is open-source. Mapbox requires MAPBOX_API_KEY.',
},
},
};
export const InteractiveSuperclusterMap = ({
clusteringRadius,
globalOpacity,
pointRadius,
renderWhileDragging,
mapRenderer,
width,
height,
}: {
clusteringRadius: number;
globalOpacity: number;
pointRadius: string | number;
renderWhileDragging: boolean;
mapRenderer: string;
width: number;
height: number;
}) => {
const theme = useTheme();
return (
<SuperChart
chartType="point_cluster_map"
width={width}
height={height}
queriesData={[{ data: generateData(theme) }]}
formData={{
clustering_radius: String(clusteringRadius),
global_opacity: globalOpacity,
map_color: '#008b8b',
map_label: [],
map_renderer: mapRenderer,
maplibre_style: 'https://tiles.openfreemap.org/styles/liberty',
mapbox_style: 'mapbox://styles/mapbox/light-v11',
pandas_aggfunc: 'sum',
point_radius: pointRadius,
point_radius_unit: 'Pixels',
render_while_dragging: renderWhileDragging,
viewport_latitude: 37.78,
viewport_longitude: -122.42,
viewport_zoom: 12,
}}
/>
);
};
export const WithMetricLabels = ({
width,
height,
}: {
width: number;
height: number;
}) => {
const theme = useTheme();
return (
<SuperChart
chartType="point_cluster_map"
width={width}
height={height}
queriesData={[{ data: generateData(theme) }]}
formData={{
clustering_radius: '60',
global_opacity: 1,
map_color: '#dc143c',
map_label: ['metric'],
map_renderer: 'maplibre',
maplibre_style:
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
pandas_aggfunc: 'sum',
point_radius: 'Auto',
point_radius_unit: 'Pixels',
render_while_dragging: true,
viewport_latitude: 37.78,
viewport_longitude: -122.42,
viewport_zoom: 12,
}}
/>
);
};
export const NoClustering = ({
width,
height,
}: {
width: number;
height: number;
}) => {
const theme = useTheme();
return (
<SuperChart
chartType="point_cluster_map"
width={width}
height={height}
queriesData={[{ data: generateData(theme) }]}
formData={{
clustering_radius: '0',
global_opacity: 0.8,
map_color: '#228b22',
map_label: [],
map_renderer: 'maplibre',
maplibre_style:
'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
pandas_aggfunc: 'sum',
point_radius: 'Auto',
point_radius_unit: 'Pixels',
render_while_dragging: true,
viewport_latitude: 37.78,
viewport_longitude: -122.42,
viewport_zoom: 12,
}}
/>
);
};

View File

@@ -0,0 +1,292 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import Supercluster, {
type Options as SuperclusterOptions,
} from 'supercluster';
import { ChartProps } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { DEFAULT_POINT_RADIUS, DEFAULT_MAX_ZOOM } from './MapLibre';
import roundDecimal from './utils/roundDecimal';
const NOOP = () => {};
// Geo precision to limit decimal places (matching legacy backend behavior)
const GEO_PRECISION = 10;
const MIN_LONGITUDE = -180;
const MAX_LONGITUDE = 180;
const MIN_LATITUDE = -90;
const MAX_LATITUDE = 90;
const MIN_ZOOM = 0;
function toFiniteNumber(
value: string | number | null | undefined,
): number | undefined {
if (value === null || value === undefined) return undefined;
const normalizedValue = typeof value === 'string' ? value.trim() : value;
if (normalizedValue === '') return undefined;
const num = Number(normalizedValue);
return Number.isFinite(num) ? num : undefined;
}
function clampNumber(
value: number | undefined,
min: number,
max: number,
): number | undefined {
if (value === undefined) return undefined;
return Math.min(max, Math.max(min, value));
}
interface PointProperties {
metric: number | string | null;
radius: number | string | null;
}
interface ClusterProperties {
metric: number;
sum: number;
squaredSum: number;
min: number;
max: number;
}
interface DataRecord {
[key: string]: string | number | null | undefined;
}
function buildGeoJSONFromRecords(
records: DataRecord[],
lonCol: string,
latCol: string,
labelCol: string | null,
pointRadiusCol: string | null,
) {
const features: GeoJSON.Feature<GeoJSON.Point, PointProperties>[] = [];
let minLon = Infinity;
let maxLon = -Infinity;
let minLat = Infinity;
let maxLat = -Infinity;
for (const record of records) {
const rawLon = record[lonCol];
const rawLat = record[latCol];
if (rawLon == null || rawLat == null) {
continue;
}
const lon = Number(rawLon);
const lat = Number(rawLat);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) {
continue;
}
const roundedLon = roundDecimal(lon, GEO_PRECISION);
const roundedLat = roundDecimal(lat, GEO_PRECISION);
minLon = Math.min(minLon, roundedLon);
maxLon = Math.max(maxLon, roundedLon);
minLat = Math.min(minLat, roundedLat);
maxLat = Math.max(maxLat, roundedLat);
const metric = labelCol != null ? (record[labelCol] ?? null) : null;
const radius =
pointRadiusCol != null ? (record[pointRadiusCol] ?? null) : null;
features.push({
type: 'Feature',
properties: { metric, radius },
geometry: {
type: 'Point',
coordinates: [roundedLon, roundedLat],
},
});
}
const bounds: [[number, number], [number, number]] | undefined =
features.length > 0
? [
[minLon, minLat],
[maxLon, maxLat],
]
: undefined;
return {
geoJSON: { type: 'FeatureCollection' as const, features },
bounds,
};
}
export default function transformProps(chartProps: ChartProps) {
const {
width,
height,
rawFormData: formData,
hooks,
queriesData,
} = chartProps;
const { onError = NOOP, setControlValue = NOOP } = hooks;
const {
all_columns_x: allColumnsX,
all_columns_y: allColumnsY,
clustering_radius: clusteringRadius,
global_opacity: globalOpacity,
map_color: maplibreColor,
map_label: maplibreLabel,
map_renderer: mapProvider,
maplibre_style: maplibreStyle,
mapbox_style: mapboxStyle = '',
pandas_aggfunc: pandasAggfunc,
point_radius: pointRadius,
point_radius_unit: pointRadiusUnit,
render_while_dragging: renderWhileDragging,
viewport_longitude: viewportLongitude,
viewport_latitude: viewportLatitude,
viewport_zoom: viewportZoom,
} = formData;
// Support two data formats:
// 1. Legacy/GeoJSON: queriesData[0].data is an object with { geoJSON, bounds, hasCustomMetric }
// 2. Tabular records: queriesData[0].data is an array of flat records from a SQL query
const rawData = queriesData[0]?.data;
const isLegacyFormat = rawData && !Array.isArray(rawData) && rawData.geoJSON;
let geoJSON: { type: 'FeatureCollection'; features: any[] };
let bounds: [[number, number], [number, number]] | undefined;
let hasCustomMetric: boolean;
if (isLegacyFormat) {
const legacy = rawData as any;
({ geoJSON } = legacy);
({ bounds } = legacy);
hasCustomMetric = legacy.hasCustomMetric ?? false;
} else {
const records: DataRecord[] = (rawData as DataRecord[]) || [];
hasCustomMetric =
maplibreLabel != null &&
maplibreLabel.length > 0 &&
maplibreLabel[0] !== 'count';
const labelCol = hasCustomMetric ? maplibreLabel[0] : null;
const pointRadiusCol =
pointRadius && pointRadius !== 'Auto' ? pointRadius : null;
const built = buildGeoJSONFromRecords(
records,
allColumnsX,
allColumnsY,
labelCol,
pointRadiusCol,
);
({ geoJSON } = built);
({ bounds } = built);
}
// Validate color — supports hex (#rrggbb) and rgb(r, g, b) formats
let rgb: string[] | null = null;
const hexMatch = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(
maplibreColor,
);
if (hexMatch) {
rgb = [
maplibreColor,
String(parseInt(hexMatch[1], 16)),
String(parseInt(hexMatch[2], 16)),
String(parseInt(hexMatch[3], 16)),
];
} else {
rgb = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/.exec(maplibreColor);
}
if (rgb === null) {
onError(t("Color field must be a hex color (#rrggbb) or 'rgb(r, g, b)'"));
// Fall back to a safe default color so the chart can still render
rgb = ['', '0', '0', '0'];
}
const opts: SuperclusterOptions<PointProperties, ClusterProperties> = {
maxZoom: DEFAULT_MAX_ZOOM,
radius: clusteringRadius,
};
if (hasCustomMetric) {
opts.map = (prop: PointProperties) => ({
metric: Number(prop.metric) || 0,
sum: Number(prop.metric) || 0,
squaredSum: (Number(prop.metric) || 0) ** 2,
min: Number(prop.metric) || 0,
max: Number(prop.metric) || 0,
});
opts.reduce = (accu: ClusterProperties, prop: ClusterProperties) => {
/* eslint-disable no-param-reassign */
accu.sum += prop.sum;
accu.squaredSum += prop.squaredSum;
accu.min = Math.min(accu.min, prop.min);
accu.max = Math.max(accu.max, prop.max);
/* eslint-enable no-param-reassign */
};
}
const clusterer = new Supercluster<PointProperties, ClusterProperties>(opts);
// Disable strict typecheck on load since Supercluster typings have namespace issues with esModuleInterop
clusterer.load(geoJSON.features as any);
return {
width,
height,
aggregatorName: pandasAggfunc,
bounds,
clusterer,
globalOpacity: Math.min(1, Math.max(0, toFiniteNumber(globalOpacity) ?? 1)),
hasCustomMetric,
mapProvider,
mapStyle:
mapProvider === 'mapbox'
? (mapboxStyle as string)
: (maplibreStyle as string),
onViewportChange({
latitude,
longitude,
zoom,
}: {
latitude: number;
longitude: number;
zoom: number;
}) {
setControlValue('viewport_longitude', longitude);
setControlValue('viewport_latitude', latitude);
setControlValue('viewport_zoom', zoom);
},
pointRadius: DEFAULT_POINT_RADIUS,
pointRadiusUnit,
renderWhileDragging,
rgb,
viewportLongitude: clampNumber(
toFiniteNumber(viewportLongitude),
MIN_LONGITUDE,
MAX_LONGITUDE,
),
viewportLatitude: clampNumber(
toFiniteNumber(viewportLatitude),
MIN_LATITUDE,
MAX_LATITUDE,
),
viewportZoom: clampNumber(
toFiniteNumber(viewportZoom),
MIN_ZOOM,
DEFAULT_MAX_ZOOM,
),
};
}

View File

@@ -16,41 +16,20 @@
* specific language governing permissions and limitations
* under the License.
*/
import getPointsFromPolygon from '../../src/utils/getPointsFromPolygon';
describe('getPointsFromPolygon', () => {
test('handle original input', () => {
expect(
getPointsFromPolygon({
polygon: [
[1, 2],
[3, 4],
],
}),
).toEqual([
[1, 2],
[3, 4],
]);
});
test('handle geojson features', () => {
expect(
getPointsFromPolygon({
polygon: {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[1, 2],
[3, 4],
],
],
},
},
}),
).toEqual([
[1, 2],
[3, 4],
]);
});
});
export function getMapboxApiKey(): string {
if (typeof document === 'undefined') {
return '';
}
try {
const appContainer = document.getElementById('app');
const dataBootstrap = appContainer?.getAttribute('data-bootstrap');
if (dataBootstrap) {
const bootstrapData = JSON.parse(dataBootstrap);
return bootstrapData?.common?.conf?.MAPBOX_API_KEY || '';
}
} catch {
// If bootstrap data is unavailable or malformed, return empty string
}
return '';
}

View File

@@ -0,0 +1,262 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { type ReactNode } from 'react';
import { render } from '@testing-library/react';
// Capture the most recent viewport props passed to the Map component
let lastMapProps: Record<string, unknown> = {};
const mockFitBounds = jest.fn();
jest.mock('react-map-gl/maplibre', () => {
const MockMap = (props: Record<string, unknown>) => {
lastMapProps = props;
return <div data-testid="map-gl">{props.children as ReactNode}</div>;
};
return { __esModule: true, Map: MockMap };
});
jest.mock('react-map-gl/mapbox', () => {
const MockMap = (props: Record<string, unknown>) => {
lastMapProps = props;
return <div data-testid="map-gl">{props.children as ReactNode}</div>;
};
return { __esModule: true, Map: MockMap };
});
jest.mock('@math.gl/web-mercator', () => ({
WebMercatorViewport: jest
.fn()
.mockImplementation(
({ width, height }: { width: number; height: number }) => ({
fitBounds: (bounds: [[number, number], [number, number]]) =>
mockFitBounds(bounds, width, height),
}),
),
}));
jest.mock('../src/components/ScatterPlotOverlay', () => {
const MockOverlay = (props: Record<string, unknown>) => (
<div data-testid="scatter-overlay" data-opacity={props.globalOpacity} />
);
return { __esModule: true, default: MockOverlay };
});
jest.mock('@apache-superset/core/theme', () => ({
useTheme: () => ({ colorTextSecondary: '#666' }),
}));
jest.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
jest.mock('../src/MapLibre.css', () => ({}));
// eslint-disable-next-line import/first
import MapLibre from '../src/MapLibre';
const defaultProps = {
width: 800,
height: 600,
clusterer: {
getClusters: jest.fn().mockReturnValue([]),
},
globalOpacity: 1,
mapProvider: 'maplibre',
mapStyle: 'https://tiles.openfreemap.org/styles/liberty',
pointRadius: 60,
pointRadiusUnit: 'Pixels',
renderWhileDragging: true,
rgb: ['', 255, 0, 0] as (string | number)[],
hasCustomMetric: false,
bounds: [
[-74.0, 40.7],
[-73.9, 40.8],
] as [[number, number], [number, number]],
onViewportChange: jest.fn(),
};
beforeEach(() => {
lastMapProps = {};
jest.clearAllMocks();
mockFitBounds.mockImplementation(
(
bounds: [[number, number], [number, number]],
width: number,
height: number,
) => ({
latitude: Number(((bounds[0][1] + bounds[1][1]) / 2).toFixed(2)),
longitude: Number(((bounds[0][0] + bounds[1][0]) / 2).toFixed(2)),
zoom: Number((10 + width / 1000 + height / 10000).toFixed(2)),
}),
);
});
test('initializes viewport from bounds', () => {
render(<MapLibre {...defaultProps} />);
expect(lastMapProps.longitude).toBe(-73.95);
expect(lastMapProps.latitude).toBe(40.75);
expect(lastMapProps.zoom).toBe(10.86);
});
test('initializes viewport from props when provided', () => {
render(
<MapLibre
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
expect(lastMapProps.longitude).toBe(-122.4);
expect(lastMapProps.latitude).toBe(37.8);
expect(lastMapProps.zoom).toBe(5);
});
test('updates viewport when viewport props change', () => {
const { rerender } = render(
<MapLibre
{...defaultProps}
viewportLongitude={-73.95}
viewportLatitude={40.75}
viewportZoom={10}
/>,
);
rerender(
<MapLibre
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
expect(lastMapProps.longitude).toBe(-122.4);
expect(lastMapProps.latitude).toBe(37.8);
expect(lastMapProps.zoom).toBe(5);
});
test('does not loop when viewport state matches new props', () => {
const { rerender } = render(
<MapLibre
{...defaultProps}
viewportLongitude={-73.95}
viewportLatitude={40.75}
viewportZoom={10}
/>,
);
rerender(
<MapLibre
{...defaultProps}
viewportLongitude={-73.95}
viewportLatitude={40.75}
viewportZoom={10}
/>,
);
expect(lastMapProps.longitude).toBe(-73.95);
expect(lastMapProps.latitude).toBe(40.75);
expect(lastMapProps.zoom).toBe(10);
});
test('passes globalOpacity to ScatterPlotOverlay', () => {
const { container } = render(
<MapLibre {...defaultProps} globalOpacity={0.5} />,
);
const overlay = container.querySelector('[data-testid="scatter-overlay"]');
expect(overlay).not.toBeNull();
expect(overlay!.getAttribute('data-opacity')).toBe('0.5');
});
test('handles undefined bounds gracefully', () => {
render(<MapLibre {...defaultProps} bounds={undefined} />);
expect(lastMapProps.longitude).toBe(0);
expect(lastMapProps.latitude).toBe(0);
expect(lastMapProps.zoom).toBe(1);
});
test('applies partial viewport props on update', () => {
const { rerender } = render(<MapLibre {...defaultProps} />);
rerender(<MapLibre {...defaultProps} viewportLongitude={-122.4} />);
expect(lastMapProps.longitude).toBe(-122.4);
// lat and zoom come from fitBounds
expect(lastMapProps.latitude).toBe(40.75);
expect(lastMapProps.zoom).toBe(10.86);
});
test('restores fitBounds when viewport props are cleared', () => {
const { rerender } = render(
<MapLibre
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
// Clear all viewport props
rerender(<MapLibre {...defaultProps} />);
// Should revert to fitBounds values
expect(lastMapProps.longitude).toBe(-73.95);
expect(lastMapProps.latitude).toBe(40.75);
expect(lastMapProps.zoom).toBe(10.86);
});
test('restores only cleared viewport props, keeps the rest', () => {
const { rerender } = render(
<MapLibre
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
// Clear only longitude, keep lat/zoom
rerender(
<MapLibre {...defaultProps} viewportLatitude={37.8} viewportZoom={5} />,
);
// Longitude reverts to fitBounds, lat/zoom stay
expect(lastMapProps.longitude).toBe(-73.95);
expect(lastMapProps.latitude).toBe(37.8);
expect(lastMapProps.zoom).toBe(5);
});
test('falls back to default viewport when cleared with undefined bounds', () => {
const { rerender } = render(
<MapLibre
{...defaultProps}
bounds={undefined}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
// Clear viewport props — no bounds to fitBounds to
rerender(<MapLibre {...defaultProps} bounds={undefined} />);
// Should fall back to {0, 0, 1}
expect(lastMapProps.longitude).toBe(0);
expect(lastMapProps.latitude).toBe(0);
expect(lastMapProps.zoom).toBe(1);
});

View File

@@ -18,7 +18,11 @@
*/
import { render } from '@testing-library/react';
import ScatterPlotGlowOverlay from '../src/ScatterPlotGlowOverlay';
import ScatterPlotOverlay from '../src/components/ScatterPlotOverlay';
import {
MIN_CLUSTER_RADIUS_RATIO,
MAX_POINT_RADIUS_RATIO,
} from '../src/components/ScatterPlotOverlay';
type MockGradient = {
addColorStop: jest.Mock<void, [number, string]>;
@@ -67,22 +71,20 @@ declare global {
var mockRedraw: unknown;
}
// Mock react-map-gl's CanvasOverlay
jest.mock('react-map-gl', () => ({
CanvasOverlay: ({ redraw }: { redraw: unknown }) => {
// Store the redraw function so tests can call it
// Mock the CanvasOverlay component to capture the redraw function
jest.mock('../src/components/CanvasOverlay', () => ({
__esModule: true,
default: ({ redraw }: { redraw: unknown }) => {
global.mockRedraw = redraw;
return <div data-testid="canvas-overlay" />;
},
}));
// Mock utility functions
jest.mock('../src/utils/luminanceFromRGB', () => ({
__esModule: true,
default: jest.fn(() => 150), // Return a value above the dark threshold
default: jest.fn(() => 150),
}));
// Test helpers
const createMockCanvas = () => {
const ctx: MockCanvasContext = {
clearRect: jest.fn(),
@@ -151,8 +153,10 @@ const defaultProps = {
rgb: ['', 255, 0, 0] as [string, number, number, number],
globalOpacity: 1,
};
const MIN_VISIBLE_POINT_RADIUS = 10;
const MAX_VISIBLE_POINT_RADIUS = 20;
const MIN_VISIBLE_POINT_RADIUS =
defaultProps.dotRadius * MIN_CLUSTER_RADIUS_RATIO;
const MAX_VISIBLE_POINT_RADIUS =
defaultProps.dotRadius * MAX_POINT_RADIUS_RATIO;
test('renders map with varying radius values in Pixels mode', () => {
const locations = [
@@ -162,7 +166,7 @@ test('renders map with varying radius values in Pixels mode', () => {
];
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -172,13 +176,11 @@ test('renders map with varying radius values in Pixels mode', () => {
const arcCalls = redrawParams.ctx.arc.mock.calls;
// With dotRadius=60, pixel-sized points should map to the visible 10-20 range.
arcCalls.forEach(call => {
expect(call[2]).toBeGreaterThanOrEqual(MIN_VISIBLE_POINT_RADIUS);
expect(call[2]).toBeLessThanOrEqual(MAX_VISIBLE_POINT_RADIUS);
});
// Ordering should be preserved: radius 10 < 50 < 100
expect(arcCalls[0][2]).toBeLessThan(arcCalls[1][2]);
expect(arcCalls[1][2]).toBeLessThan(arcCalls[2][2]);
});
@@ -192,7 +194,7 @@ test('handles dataset with uniform radius values', () => {
expect(() => {
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -211,7 +213,7 @@ test('renders successfully when data contains non-finite values', () => {
expect(() => {
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -229,7 +231,7 @@ test('handles radius values provided as strings', () => {
];
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -256,7 +258,7 @@ test('treats blank radius strings as missing values', () => {
];
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -280,7 +282,7 @@ test('renders points when radius values are missing', () => {
expect(() => {
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -304,7 +306,7 @@ test('renders both cluster and non-cluster points correctly', () => {
expect(() => {
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -323,7 +325,7 @@ test('renders map with multiple points with different radius values', () => {
expect(() => {
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -341,7 +343,7 @@ test('renders map with Kilometers mode', () => {
expect(() => {
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Kilometers"
@@ -360,7 +362,7 @@ test('renders map with Miles mode', () => {
expect(() => {
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Miles"
@@ -378,7 +380,7 @@ test('displays metric property labels on points', () => {
expect(() => {
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -391,7 +393,7 @@ test('displays metric property labels on points', () => {
test('handles empty dataset without errors', () => {
expect(() => {
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={[]}
pointRadiusUnit="Pixels"
@@ -410,7 +412,7 @@ test('handles extreme outlier radius values without breaking', () => {
expect(() => {
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -431,7 +433,7 @@ test('renders successfully with mixed extreme and negative radius values', () =>
expect(() => {
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -456,7 +458,7 @@ test('cluster radius is always >= max individual point radius in Pixels mode', (
];
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -467,9 +469,7 @@ test('cluster radius is always >= max individual point radius in Pixels mode', (
const arcCalls = redrawParams.ctx.arc.mock.calls;
// cluster with label=1 (index 0) should not be smaller than the largest point bubble
expect(arcCalls[0][2]).toBeGreaterThanOrEqual(MAX_VISIBLE_POINT_RADIUS);
// point radii span the configured pixel range
expect(arcCalls[1][2]).toBe(MIN_VISIBLE_POINT_RADIUS);
expect(arcCalls[2][2]).toBe(MAX_VISIBLE_POINT_RADIUS);
expect(arcCalls[0][2]).toBeGreaterThanOrEqual(arcCalls[2][2]);
@@ -490,7 +490,7 @@ test('largest cluster gets full dotRadius', () => {
];
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -500,7 +500,6 @@ test('largest cluster gets full dotRadius', () => {
const redrawParams = triggerRedraw();
const arcCalls = redrawParams.ctx.arc.mock.calls;
// The largest cluster (label=100, maxLabel=100) should get full radius
expect(arcCalls[1][2]).toBe(defaultProps.dotRadius);
});
@@ -524,7 +523,7 @@ test('cluster radii preserve proportional ordering', () => {
];
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -552,7 +551,7 @@ test('negative cluster label produces valid finite radius', () => {
];
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -581,7 +580,7 @@ test('ignores non-finite cluster labels when computing cluster scaling bounds',
];
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -606,7 +605,7 @@ test('single cluster with small maxLabel gets full dotRadius', () => {
];
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -615,7 +614,6 @@ test('single cluster with small maxLabel gets full dotRadius', () => {
const redrawParams = triggerRedraw();
const arcCalls = redrawParams.ctx.arc.mock.calls;
// When there's only one cluster, label=maxLabel, so it gets full radius
expect(arcCalls[0][2]).toBe(defaultProps.dotRadius);
});
@@ -639,7 +637,7 @@ test('all-negative cluster labels produce differentiated radii by magnitude', ()
];
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -652,7 +650,6 @@ test('all-negative cluster labels produce differentiated radii by magnitude', ()
const rNeg10 = arcCalls[1][2];
const rNeg1 = arcCalls[2][2];
// Higher magnitude = bigger circle: |-100| > |-10| > |-1|
expect(rNeg1).toBeLessThan(rNeg10);
expect(rNeg10).toBeLessThan(rNeg100);
expect(Number.isFinite(rNeg100)).toBe(true);
@@ -682,7 +679,7 @@ test('mixed positive-and-negative cluster labels size by magnitude', () => {
];
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -695,7 +692,6 @@ test('mixed positive-and-negative cluster labels size by magnitude', () => {
const rZero = arcCalls[1][2];
const r100 = arcCalls[2][2];
// Magnitude ordering: |0| < |-50| < |100|
expect(rZero).toBeLessThan(rNeg50);
expect(rNeg50).toBeLessThan(r100);
expect(rZero).toBeGreaterThanOrEqual(MIN_VISIBLE_POINT_RADIUS);
@@ -722,7 +718,7 @@ test('all-identical negative labels get equal full radii', () => {
];
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -750,7 +746,7 @@ test('single negative cluster gets full radius', () => {
];
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -772,7 +768,7 @@ test('large negative cluster labels are abbreviated', () => {
];
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -812,7 +808,7 @@ test.each([
];
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
aggregation={aggregation}
@@ -846,7 +842,7 @@ test('zero-value cluster is visible with minimum radius', () => {
];
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -877,7 +873,7 @@ test('all-zero clusters use a finite radius', () => {
];
render(
<ScatterPlotGlowOverlay
<ScatterPlotOverlay
{...defaultProps}
locations={locations}
aggregation="sum"

View File

@@ -42,19 +42,23 @@ type TransformPropsResult = {
viewportLongitude?: number;
viewportLatitude?: number;
viewportZoom?: number;
rgb?: string[] | null;
};
const baseFormData = {
clusteringRadius: 60,
globalOpacity: 0.8,
mapboxColor: 'rgb(0, 139, 139)',
mapboxStyle: 'mapbox://styles/mapbox/light-v9',
pandasAggfunc: 'sum',
pointRadiusUnit: 'Pixels',
renderWhileDragging: true,
viewportLongitude: -73.935242,
viewportLatitude: 40.73061,
viewportZoom: 9,
all_columns_x: 'lon',
all_columns_y: 'lat',
clustering_radius: 60,
global_opacity: 0.8,
map_color: 'rgb(0, 139, 139)',
map_renderer: 'maplibre',
maplibre_style: 'https://tiles.openfreemap.org/styles/liberty',
pandas_aggfunc: 'sum',
point_radius_unit: 'Pixels',
render_while_dragging: true,
viewport_longitude: -73.935242,
viewport_latitude: 40.73061,
viewport_zoom: 9,
};
const baseQueriesData = [
@@ -66,7 +70,6 @@ const baseQueriesData = [
] as [[number, number], [number, number]],
geoJSON: { features: [] },
hasCustomMetric: false,
mapboxApiKey: 'test-api-key',
},
},
];
@@ -88,15 +91,15 @@ function getTransformPropsResult(
}
test('extracts globalOpacity from formData', () => {
const result = getTransformPropsResult({ globalOpacity: 0.5 });
const result = getTransformPropsResult({ global_opacity: 0.5 });
expect(result.globalOpacity).toBe(0.5);
});
test('extracts viewport values from formData', () => {
const result = getTransformPropsResult({
viewportLongitude: -122.4,
viewportLatitude: 37.8,
viewportZoom: 12,
viewport_longitude: -122.4,
viewport_latitude: 37.8,
viewport_zoom: 12,
});
expect(result).toEqual(
expect.objectContaining({
@@ -109,9 +112,9 @@ test('extracts viewport values from formData', () => {
test('clamps viewport values to safe map ranges', () => {
const result = getTransformPropsResult({
viewportLongitude: 190,
viewportLatitude: -100,
viewportZoom: 99,
viewport_longitude: 190,
viewport_latitude: -100,
viewport_zoom: 99,
});
expect(result).toEqual(
expect.objectContaining({
@@ -148,9 +151,9 @@ test('provides onViewportChange callback that updates control values', () => {
test('normalizes string viewport values to numbers', () => {
const result = getTransformPropsResult({
viewportLongitude: '-122.4',
viewportLatitude: '37.8',
viewportZoom: '12',
viewport_longitude: '-122.4',
viewport_latitude: '37.8',
viewport_zoom: '12',
});
expect(result.viewportLongitude).toBe(-122.4);
expect(result.viewportLatitude).toBe(37.8);
@@ -159,9 +162,9 @@ test('normalizes string viewport values to numbers', () => {
test('normalizes empty viewport values to undefined', () => {
const result = getTransformPropsResult({
viewportLongitude: '',
viewportLatitude: '',
viewportZoom: '',
viewport_longitude: '',
viewport_latitude: '',
viewport_zoom: '',
});
expect(result.viewportLongitude).toBeUndefined();
expect(result.viewportLatitude).toBeUndefined();
@@ -170,9 +173,9 @@ test('normalizes empty viewport values to undefined', () => {
test('normalizes whitespace-only viewport values to undefined', () => {
const result = getTransformPropsResult({
viewportLongitude: ' ',
viewportLatitude: '\t',
viewportZoom: ' \n ',
viewport_longitude: ' ',
viewport_latitude: '\t',
viewport_zoom: ' \n ',
});
expect(result.viewportLongitude).toBeUndefined();
expect(result.viewportLatitude).toBeUndefined();
@@ -180,31 +183,31 @@ test('normalizes whitespace-only viewport values to undefined', () => {
});
test('normalizes string opacity to number', () => {
const result = getTransformPropsResult({ globalOpacity: '0.5' });
const result = getTransformPropsResult({ global_opacity: '0.5' });
expect(result.globalOpacity).toBe(0.5);
});
test('defaults empty opacity to 1', () => {
const result = getTransformPropsResult({ globalOpacity: '' });
const result = getTransformPropsResult({ global_opacity: '' });
expect(result.globalOpacity).toBe(1);
});
test('defaults whitespace-only opacity to 1', () => {
const result = getTransformPropsResult({ globalOpacity: ' ' });
const result = getTransformPropsResult({ global_opacity: ' ' });
expect(result.globalOpacity).toBe(1);
});
test('clamps opacity to [0, 1] range', () => {
expect(getTransformPropsResult({ globalOpacity: 5 }).globalOpacity).toBe(1);
expect(getTransformPropsResult({ globalOpacity: -1 }).globalOpacity).toBe(0);
expect(getTransformPropsResult({ global_opacity: 5 }).globalOpacity).toBe(1);
expect(getTransformPropsResult({ global_opacity: -1 }).globalOpacity).toBe(0);
});
test('passes through numeric values unchanged', () => {
const result = getTransformPropsResult({
viewportLongitude: -122.4,
viewportLatitude: 37.8,
viewportZoom: 12,
globalOpacity: 0.8,
viewport_longitude: -122.4,
viewport_latitude: 37.8,
viewport_zoom: 12,
global_opacity: 0.8,
});
expect(result.viewportLongitude).toBe(-122.4);
expect(result.viewportLatitude).toBe(37.8);
@@ -212,19 +215,18 @@ test('passes through numeric values unchanged', () => {
expect(result.globalOpacity).toBe(0.8);
});
test('calls onError and returns empty object for invalid color', () => {
test('calls onError and falls back to black for invalid color', () => {
const onError = jest.fn();
const chartProps = new ChartProps({
formData: { ...baseFormData, mapboxColor: 'invalid-color' },
formData: { ...baseFormData, map_color: 'invalid-color' },
width: 800,
height: 600,
queriesData: baseQueriesData,
hooks: { onError },
theme: supersetTheme,
});
const result = transformProps(chartProps);
expect(onError).toHaveBeenCalledWith(
"Color field must be of form 'rgb(%d, %d, %d)'",
);
expect(result).toEqual({});
const result = transformProps(chartProps) as TransformPropsResult;
expect(onError).toHaveBeenCalled();
// Falls back to black instead of returning empty object
expect(result.rgb).toEqual(['', '0', '0', '0']);
});

View File

@@ -1,12 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
// Path Resolution: Override baseUrl to maintain correct path mappings from parent config
// (e.g., "@apache-superset/core" -> "./packages/superset-core/src")
"baseUrl": "../..",
// Directory Overrides: Parent config paths are relative to frontend root,
// but packages need paths relative to their own directory
"outDir": "lib",
"rootDir": "src",
"declarationDir": "lib"

View File

@@ -1,4 +1,4 @@
/**
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
@@ -16,10 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { hexToRGB } from '../../src/utils/colors';
describe('colors', () => {
test('hexToRGB()', () => {
expect(hexToRGB('#ffffff')).toEqual([255, 255, 255, 255]);
});
});
declare module '*.png' {
const value: string;
export default value;
}
declare module '*.jpg' {
const value: string;
export default value;
}

View File

@@ -345,6 +345,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
hasServerPageLengthChanged,
serverPageLength,
slice_id,
columnLabelToNameMap = {},
} = props;
const comparisonColumns = useMemo(
@@ -457,19 +458,22 @@ export default function TableChart<D extends DataRecord = DataRecord>(
groupBy.length === 0
? []
: groupBy.map(col => {
// Resolve adhoc column labels back to original column names
// so that cross-filters work on the receiving chart
const resolvedCol = columnLabelToNameMap[col] ?? col;
const val = ensureIsArray(updatedFilters?.[col]);
if (!val.length)
return {
col,
col: resolvedCol,
op: 'IS NULL' as const,
};
return {
col,
col: resolvedCol,
op: 'IN' as const,
val: val.map(el =>
el instanceof Date ? el.getTime() : el!,
),
grain: col === DTTM_ALIAS ? timeGrain : undefined,
grain: resolvedCol === DTTM_ALIAS ? timeGrain : undefined,
};
}),
},
@@ -485,7 +489,13 @@ export default function TableChart<D extends DataRecord = DataRecord>(
isCurrentValueSelected: isActiveFilterValue(key, value),
};
},
[filters, isActiveFilterValue, timestampFormatter, timeGrain],
[
filters,
isActiveFilterValue,
timestampFormatter,
timeGrain,
columnLabelToNameMap,
],
);
const toggleFilter = useCallback(

View File

@@ -796,45 +796,65 @@ const config: ControlPanelConfig = {
},
);
}
const { colnames, coltypes } =
const { colnames: queryColnames, coltypes: queryColtypes } =
chart?.queriesResponse?.[0] ?? {};
const allColumns =
Array.isArray(colnames) && Array.isArray(coltypes)
? [
{
value: ObjectFormattingEnum.ENTIRE_ROW,
label: t('entire row'),
dataType: GenericDataType.String,
},
...colnames.map((colname: string, index: number) => ({
const hasQueryColumns =
Array.isArray(queryColnames) &&
Array.isArray(queryColtypes) &&
queryColnames.length > 0;
// Fall back to datasource columns when query results are empty
const datasourceColumns = ensureIsArray(
(explore?.datasource as Dataset)?.columns,
);
const colnames = hasQueryColumns
? queryColnames
: datasourceColumns.map(
(col: ColumnMeta) => col.column_name,
);
const coltypes = hasQueryColumns
? queryColtypes
: datasourceColumns.map(
(col: ColumnMeta) =>
col.type_generic ?? GenericDataType.String,
);
const hasColumns = colnames.length > 0;
const allColumns = hasColumns
? [
{
value: ObjectFormattingEnum.ENTIRE_ROW,
label: t('entire row'),
dataType: GenericDataType.String,
},
...colnames.map((colname: string, index: number) => ({
value: colname,
label: Array.isArray(verboseMap)
? colname
: (verboseMap[colname] ?? colname),
dataType: coltypes[index],
})),
]
: [];
const numericColumns = hasColumns
? colnames.reduce((acc, colname, index) => {
if (
coltypes[index] === GenericDataType.Numeric ||
(!hasTimeComparison &&
(coltypes[index] === GenericDataType.String ||
coltypes[index] === GenericDataType.Boolean))
) {
acc.push({
value: colname,
label: Array.isArray(verboseMap)
? colname
: (verboseMap[colname] ?? colname),
dataType: coltypes[index],
})),
]
: [];
const numericColumns =
Array.isArray(colnames) && Array.isArray(coltypes)
? colnames.reduce((acc, colname, index) => {
if (
coltypes[index] === GenericDataType.Numeric ||
(!hasTimeComparison &&
(coltypes[index] === GenericDataType.String ||
coltypes[index] === GenericDataType.Boolean))
) {
acc.push({
value: colname,
label: Array.isArray(verboseMap)
? colname
: (verboseMap[colname] ?? colname),
dataType: coltypes[index],
});
}
return acc;
}, [])
: [];
});
}
return acc;
}, [])
: [];
const columnOptions = hasTimeComparison
? processComparisonColumns(
numericColumns || [],

View File

@@ -29,6 +29,7 @@ import {
getNumberFormatter,
getTimeFormatter,
getTimeFormatterForGranularity,
isAdhocColumn,
normalizeCurrency,
NumberFormats,
QueryMode,
@@ -532,6 +533,20 @@ const transformProps = (
comparison_type,
slice_id,
} = formData;
// Build a mapping from column labels to original column names.
// When a user creates an adhoc column with a custom label (e.g. sqlExpression: "state",
// label: "State_Renamed"), the query result uses the label as the column name.
// Cross-filtering needs the original column name to work on the receiving chart.
const columnLabelToNameMap: Record<string, string> = {};
const formColumns = ensureIsArray(
queryMode === QueryMode.Raw ? formData.all_columns : formData.groupby,
);
formColumns.forEach(col => {
if (isAdhocColumn(col) && col.label && col.label !== col.sqlExpression) {
columnLabelToNameMap[col.label] = col.sqlExpression;
}
});
const isUsingTimeComparison =
!isEmpty(time_compare) &&
queryMode === QueryMode.Aggregate &&
@@ -791,6 +806,7 @@ const transformProps = (
hasServerPageLengthChanged,
serverPageLength,
slice_id,
columnLabelToNameMap,
};
};

View File

@@ -124,6 +124,10 @@ export interface TableChartTransformedProps<D extends DataRecord = DataRecord> {
hasServerPageLengthChanged: boolean;
serverPageLength: number;
slice_id: number;
// Maps column labels (used as keys in query results) back to original
// column names for cross-filtering, so that adhoc columns with custom labels
// emit the correct column name in cross-filter data masks
columnLabelToNameMap?: Record<string, string>;
}
export default {};

View File

@@ -1787,5 +1787,145 @@ describe('plugin-chart-table', () => {
});
});
});
test('should build columnLabelToNameMap for adhoc columns with custom labels', () => {
const result = transformProps({
...testData.basic,
rawFormData: {
...testData.basic.rawFormData,
query_mode: QueryMode.Aggregate,
groupby: [
{
sqlExpression: 'name',
label: 'Name_Renamed',
expressionType: 'SQL',
},
],
metrics: ['sum__num'],
},
emitCrossFilters: true,
queriesData: [
{
...testData.basic.queriesData[0],
colnames: ['Name_Renamed', 'sum__num'],
coltypes: [GenericDataType.String, GenericDataType.Numeric],
data: [{ Name_Renamed: 'Michael', sum__num: 2467063 }],
},
],
});
expect(result.columnLabelToNameMap).toEqual({
Name_Renamed: 'name',
});
});
test('should not populate columnLabelToNameMap for physical columns', () => {
const result = transformProps({
...testData.basic,
rawFormData: {
...testData.basic.rawFormData,
query_mode: QueryMode.Aggregate,
groupby: ['name'],
metrics: ['sum__num'],
},
emitCrossFilters: true,
queriesData: [
{
...testData.basic.queriesData[0],
colnames: ['name', 'sum__num'],
coltypes: [GenericDataType.String, GenericDataType.Numeric],
data: [{ name: 'Michael', sum__num: 2467063 }],
},
],
});
expect(result.columnLabelToNameMap).toEqual({});
});
test('should not populate columnLabelToNameMap when adhoc label matches sqlExpression', () => {
const result = transformProps({
...testData.basic,
rawFormData: {
...testData.basic.rawFormData,
query_mode: QueryMode.Aggregate,
groupby: [
{
sqlExpression: 'name',
label: 'name',
expressionType: 'SQL',
},
],
metrics: ['sum__num'],
},
emitCrossFilters: true,
queriesData: [
{
...testData.basic.queriesData[0],
colnames: ['name', 'sum__num'],
coltypes: [GenericDataType.String, GenericDataType.Numeric],
data: [{ name: 'Michael', sum__num: 2467063 }],
},
],
});
expect(result.columnLabelToNameMap).toEqual({});
});
test('cross-filter on adhoc column with custom label emits original column name', () => {
const setDataMask = jest.fn();
const baseProps = transformProps({
...testData.basic,
rawFormData: {
...testData.basic.rawFormData,
query_mode: QueryMode.Aggregate,
groupby: [
{
sqlExpression: 'name',
label: 'Name_Renamed',
expressionType: 'SQL',
},
],
metrics: ['sum__num'],
},
filterState: { filters: {} },
ownState: {},
hooks: {
onAddFilter: jest.fn(),
setDataMask,
onContextMenu: jest.fn(),
},
emitCrossFilters: true,
queriesData: [
{
...testData.basic.queriesData[0],
colnames: ['Name_Renamed', 'sum__num'],
coltypes: [GenericDataType.String, GenericDataType.Numeric],
data: [
{ Name_Renamed: 'Michael', sum__num: 2467063 },
{ Name_Renamed: 'Joe', sum__num: 2467 },
],
},
],
});
render(
<ProviderWrapper>
<TableChart {...baseProps} emitCrossFilters sticky={false} />
</ProviderWrapper>,
);
// Verify the table rendered with data
expect(screen.getByText('Michael')).toBeInTheDocument();
// Find the td cell containing "Michael" and click it
const cell = screen.getByText('Michael').closest('td')!;
fireEvent.click(cell);
expect(setDataMask).toHaveBeenCalled();
const lastCall =
setDataMask.mock.calls[setDataMask.mock.calls.length - 1][0];
const { filters } = lastCall.extraFormData;
expect(filters).toHaveLength(1);
// Should emit the original column name, not the label
expect(filters[0].col).toBe('name');
expect(filters[0].val).toEqual(['Michael']);
});
});
});

View File

@@ -55,11 +55,12 @@ const createMockControlState = (value: string[] | undefined): ControlState => ({
const createMockExplore = (
timeCompareValue: string[] | undefined,
datasourceColumns: Partial<Dataset>['columns'] = [],
): ControlPanelState => ({
slice: { slice_id: 123 },
datasource: {
verbose_map: { col1: 'Column 1', col2: 'Column 2' },
columns: [],
columns: datasourceColumns,
} as Partial<Dataset> as Dataset,
controls: {
time_compare: createMockControlState(timeCompareValue),
@@ -206,3 +207,122 @@ test('static extraColorChoices removed from config', () => {
expect(controlConfig?.extraColorChoices).toBeUndefined();
});
test('columnOptions falls back to datasource columns when queriesResponse is empty', () => {
const controlConfig = findConditionalFormattingControl();
expect(controlConfig).toBeTruthy();
const datasourceColumns = [
{ column_name: 'revenue', type_generic: GenericDataType.Numeric },
{ column_name: 'name', type_generic: GenericDataType.String },
];
const explore = createMockExplore(undefined, datasourceColumns);
const chart = { chartStatus: 'success' as const, queriesResponse: null };
const result = controlConfig!.mapStateToProps!(
explore,
createMockControlStateForConditionalFormatting(),
chart,
);
expect(result.columnOptions).toEqual(
expect.arrayContaining([
expect.objectContaining({ value: 'revenue' }),
expect.objectContaining({ value: 'name' }),
]),
);
expect(result.allColumns).toEqual(
expect.arrayContaining([
expect.objectContaining({ value: 'revenue' }),
expect.objectContaining({ value: 'name' }),
]),
);
});
test('columnOptions prefers queriesResponse over datasource columns', () => {
const controlConfig = findConditionalFormattingControl();
expect(controlConfig).toBeTruthy();
const datasourceColumns = [
{ column_name: 'revenue', type_generic: GenericDataType.Numeric },
{ column_name: 'extra_col', type_generic: GenericDataType.String },
];
const explore = createMockExplore(undefined, datasourceColumns);
const chart = createMockChart();
const result = controlConfig!.mapStateToProps!(
explore,
createMockControlStateForConditionalFormatting(),
chart,
);
expect(result.columnOptions).toEqual(
expect.arrayContaining([
expect.objectContaining({ value: 'col1' }),
expect.objectContaining({ value: 'col2' }),
]),
);
expect(result.columnOptions).not.toEqual(
expect.arrayContaining([expect.objectContaining({ value: 'extra_col' })]),
);
});
test('columnOptions falls back to datasource when queriesResponse has empty colnames', () => {
const controlConfig = findConditionalFormattingControl();
expect(controlConfig).toBeTruthy();
const datasourceColumns = [
{ column_name: 'revenue', type_generic: GenericDataType.Numeric },
];
const explore = createMockExplore(undefined, datasourceColumns);
const chart = {
chartStatus: 'success' as const,
queriesResponse: [{ colnames: [], coltypes: [] }],
};
const result = controlConfig!.mapStateToProps!(
explore,
createMockControlStateForConditionalFormatting(),
chart,
);
expect(result.columnOptions).toEqual(
expect.arrayContaining([expect.objectContaining({ value: 'revenue' })]),
);
});
test('columnOptions returns empty when both queriesResponse and datasource have no columns', () => {
const controlConfig = findConditionalFormattingControl();
expect(controlConfig).toBeTruthy();
const explore = createMockExplore(undefined, []);
const chart = { chartStatus: 'success' as const, queriesResponse: null };
const result = controlConfig!.mapStateToProps!(
explore,
createMockControlStateForConditionalFormatting(),
chart,
);
expect(result.columnOptions).toEqual([]);
expect(result.allColumns).toEqual([]);
});
test('columnOptions defaults type_generic to String when missing from datasource columns', () => {
const controlConfig = findConditionalFormattingControl();
expect(controlConfig).toBeTruthy();
const datasourceColumns = [{ column_name: 'untyped_col' }];
const explore = createMockExplore(undefined, datasourceColumns);
const chart = { chartStatus: 'success' as const, queriesResponse: null };
const result = controlConfig!.mapStateToProps!(
explore,
createMockControlStateForConditionalFormatting(),
chart,
);
expect(result.columnOptions).toEqual(
expect.arrayContaining([
expect.objectContaining({
value: 'untyped_col',
dataType: GenericDataType.String,
}),
]),
);
});

View File

@@ -1,7 +1,7 @@
{
"name": "@superset-ui/legacy-preset-chart-deckgl",
"version": "0.20.4",
"description": "Superset Legacy Chart - deck.gl",
"name": "@superset-ui/preset-chart-deckgl",
"version": "1.0.0",
"description": "Superset Chart Plugin - deck.gl (MapLibre)",
"keywords": [
"superset"
],
@@ -12,7 +12,7 @@
"repository": {
"type": "git",
"url": "https://github.com/apache/superset.git",
"directory": "superset-frontend/packages/legacy-preset-chart-deckgl"
"directory": "superset-frontend/plugins/preset-chart-deckgl"
},
"license": "Apache-2.0",
"author": "Superset",
@@ -29,14 +29,13 @@
"@deck.gl/extensions": "~9.2.9",
"@deck.gl/geo-layers": "~9.2.5",
"@deck.gl/layers": "~9.2.5",
"@deck.gl/mapbox": "~9.2.5",
"@deck.gl/mesh-layers": "~9.2.5",
"@deck.gl/react": "~9.2.11",
"@luma.gl/constants": "~9.2.5",
"@luma.gl/core": "~9.2.5",
"@luma.gl/engine": "~9.2.6",
"@luma.gl/shadertools": "~9.2.6",
"@luma.gl/webgl": "~9.2.6",
"@mapbox/tiny-sdf": "^2.0.7",
"@mapbox/geojson-extent": "^1.0.1",
"@math.gl/web-mercator": "^4.1.0",
"@types/d3-array": "^3.2.2",
@@ -47,10 +46,12 @@
"d3-scale": "^4.0.2",
"handlebars": "^4.7.9",
"lodash": "^4.18.1",
"maplibre-gl": "^5.0.0",
"mousetrap": "^1.6.5",
"ngeohash": "^0.6.3",
"prop-types": "^15.8.1",
"underscore": "^1.13.8",
"react-map-gl": "^8.0.0",
"underscore": "^1.13.7",
"urijs": "^1.19.11",
"xss": "^1.0.15"
},
@@ -65,10 +66,14 @@
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"dayjs": "^1.11.19",
"mapbox-gl": "*",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-map-gl": "^6.1.19"
"mapbox-gl": ">=1.0.0",
"react": "^17.0.2 || ^19.0.0",
"react-dom": "^17.0.2 || ^19.0.0"
},
"peerDependenciesMeta": {
"mapbox-gl": {
"optional": true
}
},
"publishConfig": {
"access": "public"

View File

@@ -38,6 +38,7 @@ import {
import type { Layer } from '@deck.gl/core';
import Legend from './components/Legend';
import { hexToRGB } from './utils/colors';
import { getMapboxApiKey } from './utils/mapbox';
import sandboxedEval from './utils/sandbox';
import fitViewport, { Viewport } from './utils/fitViewport';
import {
@@ -83,7 +84,6 @@ function getCategories(fd: QueryFormData, data: JsonObject[]) {
export type CategoricalDeckGLContainerProps = {
datasource: Datasource;
formData: QueryFormData;
mapboxApiKey: string;
getPoints: (data: JsonObject[]) => Point[];
height: number;
width: number;
@@ -155,7 +155,7 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
switch (selectedColorScheme) {
case COLOR_SCHEME_TYPES.fixed_color: {
color = fd.color_picker || { r: 0, g: 0, b: 0, a: 100 };
color = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
const colorArray = [color.r, color.g, color.b, color.a * 255];
return data.map(d => ({ ...d, color: colorArray }));
@@ -166,7 +166,7 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
r: 0,
g: 0,
b: 0,
a: 100,
a: 1,
};
const colorArray = [
fallbackColor.r,
@@ -325,8 +325,15 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
viewport={viewport}
layers={getLayers()}
setControlValue={props.setControlValue}
mapStyle={props.formData.mapbox_style}
mapboxApiAccessToken={props.mapboxApiKey}
mapStyle={
props.formData.map_renderer === 'mapbox'
? props.formData.mapbox_style
: props.formData.maplibre_style
}
mapProvider={
props.formData.map_renderer === 'mapbox' ? 'mapbox' : 'maplibre'
}
mapboxApiKey={getMapboxApiKey()}
width={props.width}
height={props.height}
/>

View File

@@ -1,7 +1,3 @@
/* eslint-disable react/jsx-sort-default-props */
/* eslint-disable react/sort-prop-types */
/* eslint-disable react/jsx-handler-names */
/* eslint-disable react/forbid-prop-types */
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
@@ -30,32 +26,32 @@ import {
useImperativeHandle,
useState,
isValidElement,
useRef,
} from 'react';
import { isEqual } from 'lodash';
import { StaticMap } from 'react-map-gl';
import DeckGL from '@deck.gl/react';
import { Map as MapLibreMap } from 'react-map-gl/maplibre';
import { Map as MapboxMap } from 'react-map-gl/mapbox';
import mapboxgl from 'mapbox-gl';
import type { Layer } from '@deck.gl/core';
import { JsonObject, JsonValue, usePrevious } from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { Device } from '@luma.gl/core';
import { styled, useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import DeckGLOverlayMapLibre from './components/DeckGLOverlayMapLibre';
import DeckGLOverlayMapbox from './components/DeckGLOverlayMapbox';
import Tooltip, { TooltipProps } from './components/Tooltip';
import 'mapbox-gl/dist/mapbox-gl.css';
import 'maplibre-gl/dist/maplibre-gl.css';
import { Viewport } from './utils/fitViewport';
import {
MAPBOX_LAYER_PREFIX,
OSM_LAYER_KEYWORDS,
TILE_LAYER_PREFIX,
buildTileLayer,
} from './utils';
const TICK = 250; // milliseconds
const DEFAULT_MAP_STYLE =
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
export type DeckGLContainerProps = {
viewport: Viewport;
setControlValue?: (control: string, value: JsonValue) => void;
mapStyle?: string;
mapboxApiAccessToken: string;
mapProvider?: 'maplibre' | 'mapbox';
mapboxApiKey?: string;
children?: ReactNode;
width: number;
height: number;
@@ -69,14 +65,6 @@ export const DeckGLContainer = memo(
const [lastUpdate, setLastUpdate] = useState<number | null>(null);
const [viewState, setViewState] = useState(props.viewport);
const prevViewport = usePrevious(props.viewport);
const glContextRef = useRef<WebGL2RenderingContext | null>(null);
useEffect(
() => () => {
glContextRef.current?.getExtension('WEBGL_lose_context')?.loseContext();
},
[],
);
useImperativeHandle(ref, () => ({ setTooltip }), []);
@@ -93,7 +81,7 @@ export const DeckGLContainer = memo(
useEffect(() => {
const timer = setInterval(tick, TICK);
return clearInterval(timer);
return () => clearInterval(timer);
}, [tick]);
useEffect(() => {
@@ -102,31 +90,12 @@ export const DeckGLContainer = memo(
}
}, [prevViewport, props.viewport]);
const onViewStateChange = useCallback(
({ viewState }: { viewState: JsonObject }) => {
setViewState(viewState as Viewport);
setLastUpdate(Date.now());
},
[],
);
const onMove = useCallback((evt: { viewState: JsonObject }) => {
setViewState(evt.viewState as Viewport);
setLastUpdate(Date.now());
}, []);
const layers = useCallback(() => {
if (
(props.mapStyle?.startsWith(TILE_LAYER_PREFIX) ||
OSM_LAYER_KEYWORDS.some((tilek: string) =>
props.mapStyle?.includes(tilek),
)) &&
props.layers.some(
l => typeof l !== 'function' && l?.id === 'tile-layer',
) === false
) {
props.layers.unshift(
buildTileLayer(
(props.mapStyle ?? '').replace(TILE_LAYER_PREFIX, ''),
'tile-layer',
),
);
}
// Support for layer factory
if (props.layers.some(l => typeof l === 'function')) {
return props.layers.map(l =>
@@ -135,7 +104,7 @@ export const DeckGLContainer = memo(
}
return props.layers as Layer[];
}, [props.layers, props.mapStyle]);
}, [props.layers]);
const isCustomTooltip = (content: ReactNode): boolean =>
isValidElement(content) &&
@@ -151,7 +120,35 @@ export const DeckGLContainer = memo(
return <Tooltip tooltip={tooltipState} />;
};
const theme = useTheme();
const { children = null, height, width } = props;
const isMapbox = props.mapProvider === 'mapbox';
const mapStyle = props.mapStyle || DEFAULT_MAP_STYLE;
if (isMapbox && !props.mapboxApiKey) {
return (
<div
style={{
width,
height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
textAlign: 'center',
color: theme.colorTextSecondary,
}}
>
{t(
'Mapbox requires a MAPBOX_API_KEY to be configured on the server.',
)}
</div>
);
}
if (isMapbox && props.mapboxApiKey) {
mapboxgl.accessToken = props.mapboxApiKey;
}
return (
<>
@@ -162,28 +159,25 @@ export const DeckGLContainer = memo(
e.stopPropagation();
}}
>
<DeckGL
controller
width={width}
height={height}
layers={layers()}
viewState={viewState}
onViewStateChange={onViewStateChange}
onAfterRender={(context: {
device: Device;
gl: WebGL2RenderingContext;
}) => {
glContextRef.current = context.gl;
}}
>
{props.mapStyle?.startsWith(MAPBOX_LAYER_PREFIX) && (
<StaticMap
preserveDrawingBuffer
mapStyle={props.mapStyle || 'light'}
mapboxApiAccessToken={props.mapboxApiAccessToken}
/>
)}
</DeckGL>
{isMapbox ? (
<MapboxMap
{...viewState}
onMove={onMove}
mapStyle={mapStyle}
style={{ width, height }}
>
<DeckGLOverlayMapbox layers={layers()} />
</MapboxMap>
) : (
<MapLibreMap
{...viewState}
onMove={onMove}
mapStyle={mapStyle}
style={{ width, height }}
>
<DeckGLOverlayMapLibre layers={layers()} />
</MapLibreMap>
)}
{children}
</div>
{renderTooltip(tooltip)}

View File

@@ -58,7 +58,7 @@ const baseMockProps = {
viz_type: 'deck_multi',
deck_slices: [1, 2],
autozoom: false,
mapbox_style: 'mapbox://styles/mapbox/light-v9',
map_style: 'mapbox://styles/mapbox/light-v9',
},
payload: {
data: {

View File

@@ -50,6 +50,7 @@ import {
import { getExploreLongUrl } from '../utils/explore';
import layerGenerators from '../layers';
import fitViewport, { Viewport } from '../utils/fitViewport';
import { getMapboxApiKey } from '../utils/mapbox';
import { TooltipProps } from '../components/Tooltip';
import { getPoints as getPointsArc } from '../layers/Arc/Arc';
@@ -377,7 +378,7 @@ const DeckMulti = (props: DeckMultiProps) => {
);
if (deckSlicesChanged || visibilityFilterChanged) {
loadLayers(formData, payload, undefined);
loadLayers(formData, payload, visibleDeckLayersFromRedux);
}
}, [
loadLayers,
@@ -387,7 +388,7 @@ const DeckMulti = (props: DeckMultiProps) => {
props,
]);
const { payload, formData, setControlValue, height, width } = props;
const { formData, setControlValue, height, width } = props;
const layers = useMemo(
() =>
@@ -401,10 +402,15 @@ const DeckMulti = (props: DeckMultiProps) => {
<MultiWrapper height={height} width={width}>
<DeckGLContainerStyledWrapper
ref={containerRef}
mapboxApiAccessToken={payload.data.mapboxApiKey}
viewport={viewport}
layers={layers}
mapStyle={formData.mapbox_style}
mapStyle={
formData.map_renderer === 'mapbox'
? formData.mapbox_style
: formData.maplibre_style
}
mapProvider={formData.map_renderer === 'mapbox' ? 'mapbox' : 'maplibre'}
mapboxApiKey={getMapboxApiKey()}
setControlValue={setControlValue}
onViewportChange={setViewport}
height={height}

View File

@@ -18,7 +18,13 @@
*/
import { t } from '@apache-superset/core/translation';
import { validateNonEmpty } from '@superset-ui/core';
import { viewport, mapboxStyle, autozoom } from '../utilities/Shared_DeckGL';
import {
viewport,
mapboxStyle,
maplibreStyle,
mapProvider,
autozoom,
} from '../utilities/Shared_DeckGL';
export default {
controlPanelSections: [
@@ -26,7 +32,9 @@ export default {
label: t('Map'),
expanded: true,
controlSetRows: [
[mapProvider],
[mapboxStyle],
[maplibreStyle],
[viewport],
[autozoom],
[

View File

@@ -0,0 +1,27 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useControl } from 'react-map-gl/maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import type { MapboxOverlayProps } from '@deck.gl/mapbox';
export default function DeckGLOverlayMapLibre(props: MapboxOverlayProps) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay(props));
overlay.setProps(props);
return null;
}

View File

@@ -0,0 +1,27 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useControl } from 'react-map-gl/mapbox';
import { MapboxOverlay } from '@deck.gl/mapbox';
import type { MapboxOverlayProps } from '@deck.gl/mapbox';
export default function DeckGLOverlayMapbox(props: MapboxOverlayProps) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay(props));
overlay.setProps(props);
return null;
}

View File

@@ -113,8 +113,14 @@ const Legend = ({
<a
href="#"
role="button"
onClick={() => toggleCategory(k)}
onDoubleClick={() => showSingleCategory(k)}
onClick={e => {
e.preventDefault();
toggleCategory(k);
}}
onDoubleClick={e => {
e.preventDefault();
showSingleCategory(k);
}}
>
<span style={style}>{icon}</span> {formatCategoryLabel(k)}
</a>

View File

@@ -42,8 +42,8 @@ const StyledDiv = styled.div<{
position: absolute;
top: ${top}px;
left: ${left}px;
zIndex: 9;
pointerEvents: none;
z-index: 9;
pointer-events: none;
${
variant === 'default'
? `
@@ -51,8 +51,8 @@ const StyledDiv = styled.div<{
margin: ${theme.sizeUnit * 2}px;
background: ${theme.colorBgElevated};
color: ${theme.colorText};
maxWidth: 300px;
fontSize: ${theme.fontSizeSM}px;
max-width: 300px;
font-size: ${theme.fontSizeSM}px;
border: 1px solid ${theme.colorBorder};
border-radius: ${theme.borderRadius}px;
box-shadow: ${theme.boxShadowSecondary};

View File

@@ -19,6 +19,7 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { isEqual } from 'lodash';
import type { Layer } from '@deck.gl/core';
import { getMapboxApiKey } from './utils/mapbox';
import {
Datasource,
QueryFormData,
@@ -182,16 +183,23 @@ export function createDeckGLComponent(
}
}, [computeLayers, prevFormData, prevFilterState, prevPayload, props]);
const { formData, payload, setControlValue, height, width } = props;
const { formData, setControlValue, height, width } = props;
return (
<div style={{ position: 'relative' }}>
<DeckGLContainerStyledWrapper
ref={containerRef}
mapboxApiAccessToken={payload.data.mapboxApiKey}
viewport={viewport}
layers={layers}
mapStyle={formData.mapbox_style}
mapStyle={
formData.map_renderer === 'mapbox'
? formData.mapbox_style
: formData.maplibre_style
}
mapProvider={
formData.map_renderer === 'mapbox' ? 'mapbox' : 'maplibre'
}
mapboxApiKey={getMapboxApiKey()}
setControlValue={setControlValue}
width={width}
height={height}
@@ -232,7 +240,6 @@ export function createCategoricalDeckGLComponent(
<CategoricalDeckGLContainer
datasource={datasource}
formData={formData}
mapboxApiKey={payload.data.mapboxApiKey}
setControlValue={setControlValue}
viewport={viewport}
getLayer={getLayer}

View File

@@ -39,6 +39,8 @@ import {
legendPosition,
viewport,
mapboxStyle,
maplibreStyle,
mapProvider,
tooltipContents,
tooltipTemplate,
deckGLCategoricalColor,
@@ -86,7 +88,12 @@ const config: ControlPanelConfig = {
},
{
label: t('Map'),
controlSetRows: [[mapboxStyle], [autozoom, viewport]],
controlSetRows: [
[mapProvider],
[mapboxStyle],
[maplibreStyle],
[autozoom, viewport],
],
},
{
label: t('Arc'),

View File

@@ -20,7 +20,7 @@
/* eslint-disable sort-keys */
/* eslint-disable no-magic-numbers */
import { SuperChart } from '@superset-ui/core';
import { ArcChartPlugin } from '@superset-ui/legacy-preset-chart-deckgl';
import { ArcChartPlugin } from '@superset-ui/preset-chart-deckgl';
import { withResizableChartDemo } from '@storybook-shared';
import payload from './payload';
import { dummyDatasource } from '@storybook-shared';
@@ -28,7 +28,7 @@ import { dummyDatasource } from '@storybook-shared';
new ArcChartPlugin().configure({ key: 'deck_arc' }).register();
export default {
title: 'Legacy Chart Plugins/legacy-preset-chart-deckgl/ArcChartPlugin',
title: 'Chart Plugins/preset-chart-deckgl/ArcChartPlugin',
decorators: [withResizableChartDemo],
args: {
strokeWidth: 1,
@@ -90,7 +90,8 @@ export const ArcChartViz = ({
row_limit: 5000,
filter_nulls: true,
adhoc_filters: [],
mapbox_style: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
map_style:
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
viewport: {
altitude: 1.5,
bearing: 8.546256357301871,

Some files were not shown because too many files have changed in this diff Show More