Compare commits

...

105 Commits

Author SHA1 Message Date
Maxime Beauchemin
ddd08b5fe7 style(dataset-modal): fix prettier formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 01:14:09 +00:00
Maxime Beauchemin
63901941ff fix(dataset-modal): fix Source tab spacing per sc-101861
- Add bottom margin to radio buttons section so lock icon/text has
  more separation from the divider line (issue 1)
- Remove last FormItem bottom margin in both virtual and physical
  datasource views to reduce excess whitespace below the SQL text box
  and table selector (issue 2)
- Move search inputs into CollectionTable toolbar via new toolbarExtra
  prop so search and "Add item" button sit on the same row
- Update CrudButtonWrapper to use flex layout with space-between for
  proper toolbar alignment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 19:47:21 +00:00
Maxime Beauchemin
42f1af87bf fix(dataset-modal): fix UI alignment issues in Edit Dataset modal
- Remove negative margin hack on .ant-tabs-top that caused tabs to overlap
  with the warning alert, replace with tighter alert bottom margin
- Remove width calc(100%-34px) and marginTop:-16 hacks on Source tab's
  table_name field that caused it to be misaligned with the database selector
- Align Columns tab search input and "Sync columns" button on the same row
  using Flex for consistent layout
- Replace hardcoded inline style={{ marginBottom: 16, width: 300 }} on
  search inputs with theme-token-based css props
- Remove leading whitespace in warning alert text
- Remove unused ColumnButtonWrapper and StyledButtonWrapper styled components
- Use css prop instead of inline style for Settings tab overflow wrapper

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 16:18:58 +00: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
Maxime Beauchemin
d63308ca37 fix(frontend): fix loading spinner positioning in Save modal and filters panel (#39205)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: yousoph <sophieyou12@gmail.com>
2026-04-08 13:23:30 -07:00
Maxime Beauchemin
63cceb6a79 refactor(plugins): replace react-icons with antd icons, remove 83MB dependency (#39184)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 13:21:34 -07:00
Maxime Beauchemin
b8b2bdedf9 fix(ace-editor): style bracket matching to blend with theme (#39182)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 13:09:14 -07:00
Maxime Beauchemin
d5017e60c3 fix(sqllab): fix table navigator schema list, pin/unpin UX, copy actions, icons, and toolbar colors (#39173)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 13:06:29 -07:00
Luiz Otavio
2e80f2a473 fix: add template_processor so Jinja gets rendered before SQLGlot parse (#39207) 2026-04-08 16:58:15 -03:00
JUST.in DO IT
4c2dd63464 fix(sqllab): Update style for code viewer container (#39075) 2026-04-08 12:42:06 -07:00
Maxime Beauchemin
62302ad8c3 perf(webpack): reduce watch mode memory usage and fix docker-compose-light env (#39183)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 12:26:49 -07:00
Maxime Beauchemin
ed659958f3 fix(sqllab): use monospace font for SQL in database error messages (#39181)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:26:25 -07:00
Maxime Beauchemin
36de05fe36 fix(plugin-chart-handlebars): improve CSS sanitization tooltip and hide when not needed (#39180)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 12:25:54 -07:00
Maxime Beauchemin
a64609f4f3 fix(explore): add left-indentation to control panel hierarchy (#39177)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 12:25:36 -07:00
Maxime Beauchemin
140f0001f2 fix(sqllab): demote "Save as new" button from primary to secondary (#39179)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 12:03:44 -07:00
Elizabeth Thompson
587fe4af63 fix(reports): propagate PlaywrightTimeout so execution transitions to ERROR state (#39176)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:00:03 -07:00
Michael S. Molina
3a3a6536b7 fix(explore): Unnecessary scroll bars appearing on charts in Explore (#39160)
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-04-08 08:33:20 -03:00
Alexandru Soare
4f695e1b4d fix(filterReports): _generate_native_filter() crashes on null/empty filterValues (#38954) 2026-04-08 13:53:18 +03:00
Maxime Beauchemin
6ba9096870 fix(explore): handle boolean false values correctly in control rendering (#39172)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 18:23:03 -07:00
dependabot[bot]
5106afb07f chore(deps): bump d3-cloud from 1.2.8 to 1.2.9 in /superset-frontend (#39145)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:16:28 -07:00
dependabot[bot]
2bd4131636 chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#39134)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:15:18 -07:00
dependabot[bot]
7e452df1cc chore(deps): bump anthropics/claude-code-action from 1.0.87 to 1.0.89 (#39132)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:14:30 -07:00
dependabot[bot]
a626d06415 chore(deps): bump caniuse-lite from 1.0.30001784 to 1.0.30001786 in /docs (#39128)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:11:13 -07:00
dependabot[bot]
d159edc9a6 chore(deps-dev): bump @swc/core from 1.15.21 to 1.15.24 in /superset-frontend (#39127)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:10:17 -07:00
dependabot[bot]
96fa2cbd2b chore(deps): update @deck.gl/aggregation-layers requirement from ~9.2.9 to ~9.2.11 in /superset-frontend/plugins/legacy-preset-chart-deckgl (#39126)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:09:49 -07:00
dependabot[bot]
9750881193 chore(deps-dev): bump @types/node from 25.5.0 to 25.5.2 in /superset-websocket (#39125)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:09:25 -07:00
dependabot[bot]
3db92021c7 chore(deps-dev): bump eslint from 10.1.0 to 10.2.0 in /superset-websocket (#39123)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 15:08:46 -07:00
dependabot[bot]
5ccfc530b2 chore(deps): bump geolib from 3.3.4 to 3.3.14 in /superset-frontend (#39092)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 14:48:08 -07:00
Amin Ghadersohi
5f9fc31ae2 feat(mcp): add get_chart_type_schema tool for on-demand schema discovery (#39142) 2026-04-07 12:07:45 -04:00
dependabot[bot]
8e811de564 chore(deps): bump hot-shots from 14.2.0 to 14.3.1 in /superset-websocket (#39147)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 22:43:50 +07:00
dependabot[bot]
027de6339b chore(deps-dev): bump jsdom from 29.0.1 to 29.0.2 in /superset-frontend (#39155)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 21:39:04 +07:00
Amin Ghadersohi
bf9aff19b5 fix(mcp): compress chart config schemas to reduce search_tools token usage (#39018) 2026-04-06 19:52:03 -04:00
SBIN2010
b05764d070 feat: Add currencies controls in country map (#39016) 2026-04-06 23:20:03 +03:00
Amin Ghadersohi
7be2acb2f3 fix(mcp): add description and certification fields to default list tool columns (#39017) 2026-04-06 13:37:52 -04:00
Amin Ghadersohi
83ad1eca26 fix(mcp): add dynamic response truncation for oversized info tool responses (#39107) 2026-04-06 12:36:03 -04:00
Amin Ghadersohi
92747246fc fix(mcp): remove JWT ValueError g.user fallback in auth layer (#39106) 2026-04-06 12:35:46 -04:00
Amin Ghadersohi
7380a59ab8 fix(mcp): fix form_data null, dataset URL, ASCII preview, and chart rename (#39109) 2026-04-06 12:34:26 -04:00
Ville Brofeldt
e56f8cc4fb fix(security_manager): custom auth_view issue (#39098) 2026-04-06 09:04:59 -07:00
Ville Brofeldt
7c79b9ab61 fix(migrations): check pre-existing foreign keys on create util (#39099) 2026-04-06 09:04:22 -07:00
Maxime Beauchemin
a62be684a0 feat(mcp): add database connection listing and info tools (#39111)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-04-06 11:34:10 -04:00
Michael S. Molina
a3dfbd7bff fix(deps): revert simple-zstd from 2.1.0 back to 1.4.2 (#39139)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 11:28:28 -03:00
Sam Firke
12eb40db01 fix(SQL Lab): handle columns without names (#38986) 2026-04-06 10:09:16 -04:00
dependabot[bot]
d796543f5a chore(deps): update @deck.gl/react requirement from ~9.2.9 to ~9.2.11 in /superset-frontend/plugins/legacy-preset-chart-deckgl (#39033)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:07:42 -07:00
dependabot[bot]
e5ae626433 chore(deps): bump dawidd6/action-download-artifact from 19 to 20 (#39081)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:06:06 -07:00
dependabot[bot]
8195574345 chore(deps): bump anthropics/claude-code-action from 1.0.85 to 1.0.87 (#39083)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:04:43 -07:00
dependabot[bot]
6b029997d9 chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#39087)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:04:04 -07:00
dependabot[bot]
7a64483e6b chore(deps-dev): bump @swc/plugin-emotion from 14.7.0 to 14.8.0 in /superset-frontend (#39088)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:03:44 -07:00
dependabot[bot]
e424b55036 chore(deps-dev): bump babel-loader from 10.1.0 to 10.1.1 in /superset-frontend (#39090)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:02:57 -07:00
dependabot[bot]
613e6d6cde chore(deps): bump d3-cloud from 1.2.8 to 1.2.9 in /superset-frontend (#39093)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 15:02:11 -07:00
Amin Ghadersohi
b3a402d936 fix(mcp): handle stale SSL connections, heatmap duplicate labels, and session rollback (#39015) 2026-04-03 16:07:29 -04:00
JUST.in DO IT
c7d175b842 fix(dashboard): remove opacity on filter dropdown (#39074) 2026-04-03 09:31:23 -07:00
Amin Ghadersohi
851bbeea48 fix(mcp): improve execute_sql response-too-large error to suggest limit parameter (#39003) 2026-04-03 10:57:31 -04:00
dependabot[bot]
c5bce756f0 chore(deps): bump yeoman-generator from 7.5.1 to 8.1.2 in /superset-frontend (#38671)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 22:13:20 -07:00
dependabot[bot]
3239f058c8 chore(deps-dev): bump baseline-browser-mapping from 2.10.10 to 2.10.13 in /superset-frontend (#39044)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 22:10:35 -07:00
dependabot[bot]
7e0c634c3a chore(deps-dev): bump typescript-eslint from 8.56.1 to 8.58.0 in /superset-websocket (#38997)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 21:46:19 -07:00
dependabot[bot]
a9ced5c881 chore(deps): bump lodash-es from 4.17.23 to 4.18.1 in /superset-frontend (#39026)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 21:45:55 -07:00
dependabot[bot]
ace5f9d8c2 chore(deps): bump lodash from 4.17.23 to 4.18.1 in /superset-frontend/cypress-base (#39027)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 21:45:28 -07:00
dependabot[bot]
0452d1515a chore(deps-dev): bump @babel/preset-env from 7.29.0 to 7.29.2 in /superset-frontend (#39028)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 21:44:57 -07:00
dependabot[bot]
0330fdeb00 chore(deps-dev): bump ts-jest from 29.4.6 to 29.4.9 in /superset-websocket (#39029)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 21:44:32 -07:00
dependabot[bot]
f2ff24d811 chore(deps): bump @ant-design/icons from 5.6.1 to 6.1.1 in /superset-frontend (#39050)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 21:42:10 -07:00
dependabot[bot]
c51132f824 chore(deps): bump aws-actions/amazon-ecr-login from 2.1.1 to 2.1.2 (#39042)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 09:43:40 +07:00
dependabot[bot]
b4cb815ebf chore(deps): bump anthropics/claude-code-action from 1.0.83 to 1.0.85 (#39037)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 09:43:05 +07:00
dependabot[bot]
08d1ddd9fb chore(deps-dev): bump mini-css-extract-plugin from 2.10.1 to 2.10.2 in /superset-frontend (#39054)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 09:33:50 +07:00
dependabot[bot]
23ac4cb3a4 chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#39051)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 09:32:55 +07:00
Joe Li
5662ecab15 chore(tests): promote Playwright experimental tests to stable (#38924)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:00:06 -07:00
Joe Li
9e27d682f6 test(alerts/reports): close backend and frontend test coverage gaps (#38591)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 11:55:24 -07:00
Joe Li
f0fcdcc76a chore: package bumps (#39014) 2026-04-02 11:53:07 -07:00
Kamil Gabryjelski
135e0f8099 fix(mcp): Created dashboard should be in draft state by default (#39011) 2026-04-02 19:28:51 +02:00
Geidō
25eea295f6 fix(reports): log exception traceback in _get_csv_data (#39069)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 18:37:57 +02:00
dependabot[bot]
c372f5980c chore(deps): bump lodash from 4.17.23 to 4.18.1 in /superset-frontend (#39043)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-04-02 23:36:36 +07:00
dependabot[bot]
3802acb1e0 chore(deps-dev): bump jest-html-reporter from 4.3.0 to 4.4.0 in /superset-frontend (#39053)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 22:08:48 +07:00
dependabot[bot]
bdb0030cf8 chore(deps-dev): bump @swc/core from 1.15.18 to 1.15.21 in /superset-frontend (#39045)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 22:07:44 +07:00
dependabot[bot]
87f0540acd chore(deps-dev): bump ts-jest from 29.4.6 to 29.4.9 in /superset-frontend (#39049)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 22:06:54 +07:00
dependabot[bot]
985d7b6a79 chore(deps-dev): bump @types/node from 25.3.5 to 25.5.0 in /superset-frontend (#39055)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 22:06:13 +07:00
dependabot[bot]
59f92f979a chore(deps-dev): bump eslint-plugin-testing-library from 7.16.1 to 7.16.2 in /superset-frontend (#39056)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 22:03:50 +07:00
dependabot[bot]
5cc286e383 chore(deps-dev): bump @playwright/test from 1.58.2 to 1.59.1 in /superset-frontend (#39057)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 21:59:29 +07:00
dependabot[bot]
26f4a5acad chore(deps): bump azure/setup-helm from 4.3.1 to 5.0.0 (#38815)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-04-02 01:28:56 -04:00
dependabot[bot]
fdd08d3b70 chore(deps): bump ws from 8.19.0 to 8.20.0 in /superset-websocket (#38994)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 01:28:52 -04:00
dependabot[bot]
1aac6c9474 chore(deps): update @deck.gl/react requirement from ~9.2.5 to ~9.2.9 in /superset-frontend/plugins/legacy-preset-chart-deckgl (#38150)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 14:38:12 -07:00
dependabot[bot]
7acb0c6d05 chore(deps-dev): bump @types/node from 25.3.3 to 25.3.5 in /superset-frontend (#38510)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Joe Li <joe@preset.io>
2026-04-01 14:27:54 -07:00
dependabot[bot]
00eb86d03f chore(deps-dev): bump @swc/plugin-emotion from 14.6.0 to 14.7.0 in /superset-frontend (#38333)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-04-01 14:27:11 -07:00
dependabot[bot]
1d0e836a29 chore(deps): update @deck.gl/extensions requirement from ~9.2.5 to ~9.2.9 in /superset-frontend/plugins/legacy-preset-chart-deckgl (#38149)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-04-01 13:30:26 -07:00
dependabot[bot]
ec6640b188 chore(deps-dev): update fs-extra requirement from ^11.3.3 to ^11.3.4 in /superset-frontend/packages/generator-superset (#38383)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-04-01 13:29:47 -07:00
Rayan Salhab
ff3b8d8398 fix(ace-editor): fix cursor misalignment in markdown editor (#38928) 2026-04-01 12:03:28 -07:00
Michael S. Molina
022342839a fix(echarts): fix stacked horizontal bar chart clipping and duplicate x-axis labels (#39012) 2026-04-01 15:50:08 -03:00
Michael S. Molina
38f0dc74f7 fix(dataset-editor): improve modal layout and fix Settings tab horizontal scroll (#39009)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
2026-04-01 15:36:17 -03:00
Amin Ghadersohi
0bae05d4a9 fix(mcp): handle table chart raw mode in query builders and sanitize dashboard titles (#38990) 2026-04-01 13:42:59 -04:00
dependabot[bot]
1bb41a6e60 chore(deps): bump anthropics/claude-code-action from 1.0.82 to 1.0.83 (#39001)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 10:20:18 -07:00
dependabot[bot]
4423134739 chore(deps): bump ioredis from 5.10.0 to 5.10.1 in /superset-websocket (#38993)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 10:19:26 -07:00
Amin Ghadersohi
190f1a59c5 fix(mcp): fix dashboard owners Pydantic crash and preserve chart preview filters (#38987) 2026-04-01 13:18:28 -04:00
dependabot[bot]
5f99d613a0 chore(deps): bump markdown-to-jsx from 9.7.6 to 9.7.8 in /superset-frontend (#38507)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 09:34:47 -07:00
dependabot[bot]
6adc816805 chore(deps): bump d3-cloud from 1.2.8 to 1.2.9 in /superset-frontend (#38438)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-04-01 09:34:01 -07:00
dependabot[bot]
aa97679327 chore(deps): update @deck.gl/aggregation-layers requirement from ~9.2.5 to ~9.2.9 in /superset-frontend/plugins/legacy-preset-chart-deckgl (#38148)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-04-01 09:32:38 -07:00
Michael S. Molina
94d8735d4b fix(query): pass datasource table to template processor for schema-aware Jinja rendering (#38984)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 13:08:12 -03:00
dependabot[bot]
64c8d652e1 chore(deps-dev): bump @types/node from 25.3.5 to 25.5.0 in /superset-websocket (#38995)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 22:48:45 +07:00
dependabot[bot]
d30c5b4eee chore(deps): bump caniuse-lite from 1.0.30001782 to 1.0.30001784 in /docs (#38998)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 22:37:29 +07:00
dependabot[bot]
8ed75787cb chore(deps-dev): bump jsdom from 28.1.0 to 29.0.1 in /superset-frontend (#38999)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 17:39:08 +07:00
michaelkremmel
4ee391e0d7 docs(db): Update crate.py with new Homepage URL (#39002) 2026-04-01 17:37:51 +07:00
431 changed files with 18990 additions and 14064 deletions

View File

@@ -76,7 +76,7 @@ jobs:
fetch-depth: 1
- name: Run Claude PR Action
uses: anthropics/claude-code-action@88c168b39e7e64da0286d812b6e9fbebb6708185 # beta
uses: anthropics/claude-code-action@6e2bd52842c65e914eba5c8badd17560bd26b5de # beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
timeout_minutes: "60"

View File

@@ -58,7 +58,7 @@ jobs:
- name: Login to Amazon ECR
if: steps.describe-services.outputs.active == 'true'
id: login-ecr
uses: aws-actions/amazon-ecr-login@183a1442edf41672e66566b7fc560e297a290896 # v2
uses: aws-actions/amazon-ecr-login@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2
- name: Delete ECR image tag
if: steps.describe-services.outputs.active == 'true'

View File

@@ -199,7 +199,7 @@ jobs:
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@183a1442edf41672e66566b7fc560e297a290896 # v2
uses: aws-actions/amazon-ecr-login@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2
- name: Load, tag and push image to ECR
id: push-image
@@ -235,7 +235,7 @@ jobs:
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@183a1442edf41672e66566b7fc560e297a290896 # v2
uses: aws-actions/amazon-ecr-login@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2
- name: Check target image exists in ECR
id: check-image

View File

@@ -70,7 +70,7 @@ jobs:
yarn install --check-cache
- name: Download database diagnostics (if triggered by integration tests)
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success'
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
continue-on-error: true
with:
workflow: superset-python-integrationtest.yml
@@ -79,7 +79,7 @@ jobs:
path: docs/src/data/
- name: Try to download latest diagnostics (for push/dispatch triggers)
if: github.event_name != 'workflow_run'
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
continue-on-error: true
with:
workflow: superset-python-integrationtest.yml

View File

@@ -111,7 +111,7 @@ jobs:
run: |
yarn install --check-cache
- name: Download database diagnostics from integration tests
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
workflow: superset-python-integrationtest.yml
run_id: ${{ github.event.workflow_run.id }}

View File

@@ -23,7 +23,7 @@ jobs:
fetch-depth: 0
- name: Set up Helm
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
with:
version: v3.16.4

View File

@@ -42,7 +42,7 @@ jobs:
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Install Helm
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
with:
version: v3.5.4

View File

@@ -115,6 +115,10 @@ services:
DATABASE_HOST: db-light
DATABASE_DB: superset_light
POSTGRES_DB: superset_light
EXAMPLES_HOST: db-light
EXAMPLES_DB: superset_light
EXAMPLES_USER: superset
EXAMPLES_PASSWORD: superset
SUPERSET_CONFIG_PATH: /app/docker/pythonpath_dev/superset_config_docker_light.py
GITHUB_HEAD_REF: ${GITHUB_HEAD_REF:-}
GITHUB_SHA: ${GITHUB_SHA:-}
@@ -137,6 +141,10 @@ services:
DATABASE_HOST: db-light
DATABASE_DB: superset_light
POSTGRES_DB: superset_light
EXAMPLES_HOST: db-light
EXAMPLES_DB: superset_light
EXAMPLES_USER: superset
EXAMPLES_PASSWORD: superset
SUPERSET_CONFIG_PATH: /app/docker/pythonpath_dev/superset_config_docker_light.py
healthcheck:
disable: true
@@ -157,6 +165,7 @@ services:
BUILD_SUPERSET_FRONTEND_IN_DOCKER: true
NPM_RUN_PRUNE: false
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
DISABLE_TS_CHECKER: "${DISABLE_TS_CHECKER:-true}"
# configuring the dev-server to use the host.docker.internal to connect to the backend
superset: "http://superset-light:8088"
# Webpack dev server must bind to 0.0.0.0 to be accessible from outside the container

View File

@@ -80,7 +80,7 @@ case "${1}" in
;;
app)
echo "Starting web app (using development server)..."
flask run -p $PORT --reload --debugger --without-threads --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*"
flask run -p $PORT --reload --debugger --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*:*/superset-frontend/*"
;;
app-gunicorn)
echo "Starting web app..."

View File

@@ -70,7 +70,7 @@
"@swc/core": "^1.15.21",
"antd": "^6.3.5",
"baseline-browser-mapping": "^2.10.13",
"caniuse-lite": "^1.0.30001782",
"caniuse-lite": "^1.0.30001786",
"docusaurus-plugin-openapi-docs": "^4.6.0",
"docusaurus-theme-openapi-docs": "^4.6.0",
"js-yaml": "^4.1.1",

File diff suppressed because it is too large Load Diff

View File

@@ -6067,10 +6067,10 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001782:
version "1.0.30001782"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz#f2b8617f998bc134701c54ce9748af44f646e062"
integrity sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001786:
version "1.0.30001786"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz#586120fc73f3c7ee82152f76acd0c37e04acefbb"
integrity sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==
ccount@^2.0.0:
version "2.0.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

@@ -86,7 +86,7 @@ cron-descriptor==1.4.5
# via apache-superset (pyproject.toml)
croniter==6.0.0
# via apache-superset (pyproject.toml)
cryptography==46.0.5
cryptography==46.0.6
# via
# apache-superset (pyproject.toml)
# paramiko
@@ -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
@@ -209,7 +209,7 @@ mako==1.3.10
# via
# apache-superset (pyproject.toml)
# alembic
markdown==3.8
markdown==3.8.1
# via apache-superset (pyproject.toml)
markdown-it-py==3.0.0
# via rich
@@ -279,7 +279,7 @@ parsedatetime==2.6
# via apache-superset (pyproject.toml)
pgsanity==0.2.9
# via apache-superset (pyproject.toml)
pillow==11.3.0
pillow==12.1.1
# via apache-superset (pyproject.toml)
platformdirs==4.3.8
# via requests-cache
@@ -293,7 +293,7 @@ prompt-toolkit==3.0.51
# via click-repl
pyarrow==16.1.0
# via apache-superset (pyproject.toml)
pyasn1==0.6.2
pyasn1==0.6.3
# via
# pyasn1-modules
# rsa
@@ -309,9 +309,9 @@ pydantic-core==2.33.2
# via pydantic
pygeohash==3.2.2
# via apache-superset (pyproject.toml)
pygments==2.19.1
pygments==2.20.0
# via rich
pyjwt==2.10.1
pyjwt==2.12.0
# via
# apache-superset (pyproject.toml)
# flask-appbuilder

View File

@@ -178,7 +178,7 @@ croniter==6.0.0
# via
# -c requirements/base-constraint.txt
# apache-superset
cryptography==46.0.5
cryptography==46.0.6
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -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
@@ -508,7 +508,7 @@ mako==1.3.10
# -c requirements/base-constraint.txt
# alembic
# apache-superset
markdown==3.8
markdown==3.8.1
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -655,7 +655,7 @@ pgsanity==0.2.9
# via
# -c requirements/base-constraint.txt
# apache-superset
pillow==11.3.0
pillow==12.1.1
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -716,7 +716,7 @@ pyarrow==16.1.0
# apache-superset
# db-dtypes
# pandas-gbq
pyasn1==0.6.2
pyasn1==0.6.3
# via
# -c requirements/base-constraint.txt
# pyasn1-modules
@@ -756,7 +756,7 @@ pygeohash==3.2.2
# via
# -c requirements/base-constraint.txt
# apache-superset
pygments==2.19.1
pygments==2.20.0
# via
# -c requirements/base-constraint.txt
# rich
@@ -764,7 +764,7 @@ pyhive==0.7.0
# via apache-superset
pyinstrument==4.4.0
# via apache-superset
pyjwt==2.10.1
pyjwt==2.12.0
# via
# -c requirements/base-constraint.txt
# apache-superset

View File

@@ -1,171 +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 { CHART_LIST } from 'cypress/utils/urls';
import { setGridMode, toggleBulkSelect } from 'cypress/utils';
import {
setFilter,
interceptBulkDelete,
interceptUpdate,
interceptDelete,
interceptFiltering,
interceptFavoriteStatus,
} from '../explore/utils';
function orderAlphabetical() {
setFilter('Sort', 'Alphabetical');
}
function openProperties() {
cy.get('[aria-label="more"]').eq(0).click();
cy.getBySel('chart-list-edit-option').click();
}
function openMenu() {
cy.get('[aria-label="more"]').eq(0).click();
}
function confirmDelete() {
cy.getBySel('delete-modal-input').type('DELETE');
cy.getBySel('modal-confirm-button').click();
}
function visitChartList() {
interceptFiltering();
interceptFavoriteStatus();
cy.visit(CHART_LIST);
cy.wait('@filtering');
cy.wait('@favoriteStatus');
}
describe('Charts list', () => {
describe('common actions', () => {
beforeEach(() => {
visitChartList();
});
it('should bulk delete correctly', () => {
cy.createSampleCharts([0, 1, 2, 3]);
interceptBulkDelete();
toggleBulkSelect();
// bulk deletes in card-view
setGridMode('card');
orderAlphabetical();
cy.getBySel('skeleton-card').should('not.exist');
cy.getBySel('styled-card').contains('1 - Sample chart').click();
cy.getBySel('styled-card').contains('2 - Sample chart').click();
cy.getBySel('bulk-select-action').contains('Delete').click();
confirmDelete();
cy.wait('@bulkDelete');
cy.getBySel('styled-card')
.eq(1)
.should('not.contain', '1 - Sample chart');
cy.getBySel('styled-card')
.eq(2)
.should('not.contain', '2 - Sample chart');
// bulk deletes in list-view
setGridMode('list');
cy.get('.loading').should('not.exist');
cy.getBySel('table-row').contains('3 - Sample chart').should('exist');
cy.getBySel('table-row').contains('4 - Sample chart').should('exist');
cy.get('[data-test="table-row"] input[type="checkbox"]').eq(0).click();
cy.get('[data-test="table-row"] input[type="checkbox"]').eq(1).click();
cy.getBySel('bulk-select-action').eq(0).contains('Delete').click();
confirmDelete();
cy.wait('@bulkDelete');
cy.get('.loading').should('exist');
cy.get('.loading').should('not.exist');
cy.getBySel('table-row').eq(0).should('not.contain', '3 - Sample chart');
cy.getBySel('table-row').eq(1).should('not.contain', '4 - Sample chart');
});
it('should delete correctly in card mode', () => {
cy.createSampleCharts([0, 1]);
interceptDelete();
// deletes in card-view
setGridMode('card');
orderAlphabetical();
cy.getBySel('styled-card').contains('1 - Sample chart');
openMenu();
cy.getBySel('chart-list-delete-option').click();
confirmDelete();
cy.wait('@delete');
cy.getBySel('styled-card')
.contains('1 - Sample chart')
.should('not.exist');
});
it('should delete correctly in list mode', () => {
cy.createSampleCharts([2, 3]);
interceptDelete();
cy.getBySel('sort-header').contains('Name').click();
// Modal closes immediately without this
cy.wait(2000);
cy.getBySel('table-row').eq(0).contains('3 - Sample chart');
cy.getBySel('delete').eq(0).click();
confirmDelete();
cy.wait('@delete');
cy.get('.loading').should('exist');
cy.get('.loading').should('not.exist');
cy.getBySel('table-row').eq(0).should('not.contain', '3 - Sample chart');
});
it('should edit correctly', () => {
cy.createSampleCharts([0]);
interceptUpdate();
// edits in card-view
setGridMode('card');
orderAlphabetical();
cy.getBySel('skeleton-card').should('not.exist');
cy.getBySel('styled-card').eq(0).contains('1 - Sample chart');
// change title
openProperties();
cy.getBySel('properties-modal-name-input').type(' | EDITED');
cy.get('button:contains("Save")').click();
cy.wait('@update');
cy.getBySel('styled-card').eq(0).contains('1 - Sample chart | EDITED');
// edits in list-view
setGridMode('list');
// Wait for list view to fully render after mode change
cy.get('.loading').should('not.exist');
cy.getBySel('table-row').should('be.visible');
// Target the specific row by chart title to avoid flakiness from row ordering
cy.getBySel('table-row')
.contains('1 - Sample chart | EDITED')
.parents('[data-test="table-row"]')
.find('[data-test="edit-alt"]')
.click();
cy.getBySel('properties-modal-name-input').clear();
cy.getBySel('properties-modal-name-input').type('1 - Sample chart');
cy.get('button:contains("Save")').click();
cy.wait('@update');
cy.getBySel('table-row').contains('1 - Sample chart').should('exist');
});
});
});

View File

@@ -1,42 +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 { DATASET_LIST_PATH } from 'cypress/utils/urls';
describe('Dataset list', () => {
before(() => {
cy.visit(DATASET_LIST_PATH);
});
xit('should open Explore on dataset name click', () => {
cy.intercept('**/api/v1/explore/**').as('explore');
cy.get('[data-test="listview-table"] [data-test="internal-link"]')
.contains('birth_names')
.click();
cy.wait('@explore');
cy.get('[data-test="datasource-control"] .title-select').contains(
'birth_names',
);
cy.get('.metric-option-label').first().contains('COUNT(*)');
cy.get('.column-option-label').first().contains('ds');
cy.get('[data-test="fast-viz-switcher"] > div:not([role="button"]')
.contains('Table')
.should('be.visible');
});
});

View File

@@ -23,18 +23,6 @@ export function interceptFiltering() {
cy.intercept('GET', `**/api/v1/chart/?q=*`).as('filtering');
}
export function interceptBulkDelete() {
cy.intercept('DELETE', `**/api/v1/chart/?q=*`).as('bulkDelete');
}
export function interceptDelete() {
cy.intercept('DELETE', `**/api/v1/chart/*`).as('delete');
}
export function interceptFavoriteStatus() {
cy.intercept('GET', '**/api/v1/chart/favorite_status/*').as('favoriteStatus');
}
export function interceptUpdate() {
cy.intercept('PUT', `**/api/v1/chart/*`).as('update');
}
@@ -43,32 +31,13 @@ export const interceptV1ChartData = (alias = 'v1Data') => {
cy.intercept('**/api/v1/chart/data*').as(alias);
};
export function interceptExploreJson(alias = 'getJson') {
cy.intercept('POST', `**/superset/explore_json/**`).as(alias);
}
export const interceptFormDataKey = () => {
cy.intercept('POST', '**/api/v1/explore/form_data').as('formDataKey');
};
export function interceptExploreGet() {
function interceptExploreGet() {
cy.intercept({
method: 'GET',
url: /.*\/api\/v1\/explore\/\?(form_data_key|dashboard_page_id|slice_id)=.*/,
}).as('getExplore');
}
export function setFilter(filter: string, option: string) {
interceptFiltering();
cy.get(`[aria-label^="${filter}"]`).first().click();
cy.get(`.ant-select-item-option[title="${option}"]`).first().click({
force: true,
});
cy.wait('@filtering');
}
export function saveChartToDashboard(chartName: string, dashboardName: string) {
interceptDashboardGet();
interceptUpdate();

View File

@@ -25,28 +25,6 @@ export interface ChartSpec {
viz: string;
}
const viewTypeIcons = {
card: 'appstore',
list: 'unordered-list',
};
export function setGridMode(type: 'card' | 'list') {
const icon = viewTypeIcons[type];
cy.get(`[aria-label="${icon}"]`).click();
}
export function toggleBulkSelect() {
cy.getBySel('bulk-select').click();
}
export function clearAllInputs() {
cy.get('body').then($body => {
if ($body.find('.ant-select-clear').length) {
cy.get('.ant-select-clear').click({ multiple: true, force: true });
}
});
}
const toSlicelike = ($chart: JQuery<HTMLElement>): Slice => {
const chartId = $chart.attr('data-test-chart-id');
const vizType = $chart.attr('data-test-viz-type');

View File

@@ -25,8 +25,3 @@ export const SUPPORTED_CHARTS_DASHBOARD =
'/superset/dashboard/supported_charts_dash/';
export const TABBED_DASHBOARD = '/superset/dashboard/tabbed_dash/';
export const DATABASE_LIST = '/databaseview/list';
export const DATASET_LIST_PATH = 'tablemodelview/list';
export const ALERT_LIST = '/alert/list/';
export const REPORT_LIST = '/report/list/';
export const LOGIN = '/login/';
export const REGISTER = '/register/';

View File

@@ -5809,9 +5809,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
@@ -13072,9 +13072,9 @@
}
},
"lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="
},
"lodash.clonedeep": {
"version": "4.5.0",

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",
@@ -169,7 +169,7 @@
"fast-glob": "^3.3.2",
"fs-extra": "^11.3.4",
"fuse.js": "^7.1.0",
"geolib": "^3.3.4",
"geolib": "^3.3.14",
"geostyler": "^18.3.1",
"geostyler-data": "^1.1.0",
"geostyler-openlayers-parser": "^5.4.1",
@@ -182,9 +182,9 @@
"js-levenshtein": "^1.1.6",
"json-bigint": "^1.0.0",
"json-stringify-pretty-compact": "^2.0.0",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"mapbox-gl": "^3.20.0",
"markdown-to-jsx": "^9.7.6",
"markdown-to-jsx": "^9.7.13",
"match-sorter": "^8.2.0",
"memoize-one": "^5.2.1",
"mousetrap": "^1.6.5",
@@ -223,7 +223,7 @@
"redux-undo": "^1.0.0-beta9-9-7",
"rison": "^0.1.1",
"scroll-into-view-if-needed": "^3.1.0",
"simple-zstd": "^2.1.0",
"simple-zstd": "^1.4.2",
"stream-browserify": "^3.0.0",
"tinycolor2": "^1.4.2",
"urijs": "^1.19.8",
@@ -244,7 +244,7 @@
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@babel/plugin-transform-modules-commonjs": "^7.28.6",
"@babel/plugin-transform-runtime": "^7.29.0",
"@babel/preset-env": "^7.29.0",
"@babel/preset-env": "^7.29.2",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@babel/register": "^7.23.7",
@@ -256,7 +256,7 @@
"@emotion/jest": "^11.14.2",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.58.2",
"@playwright/test": "^1.59.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-actions": "^8.6.17",
"@storybook/addon-controls": "^8.6.17",
@@ -270,8 +270,8 @@
"@storybook/test": "^8.6.15",
"@storybook/test-runner": "^0.17.0",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.18",
"@swc/plugin-emotion": "^14.6.0",
"@swc/core": "^1.15.24",
"@swc/plugin-emotion": "^14.8.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "^6.9.1",
@@ -284,7 +284,7 @@
"@types/js-levenshtein": "^1.1.3",
"@types/json-bigint": "^1.0.4",
"@types/mousetrap": "^1.6.15",
"@types/node": "^25.3.3",
"@types/node": "^25.5.0",
"@types/react": "^17.0.83",
"@types/react-dom": "^17.0.26",
"@types/react-loadable": "^5.5.11",
@@ -301,11 +301,11 @@
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"babel-jest": "^30.0.2",
"babel-loader": "^10.1.0",
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"baseline-browser-mapping": "^2.10.10",
"baseline-browser-mapping": "^2.10.13",
"cheerio": "1.2.0",
"concurrently": "^9.2.1",
"copy-webpack-plugin": "^14.0.0",
@@ -327,7 +327,7 @@
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.9.2",
"eslint-plugin-storybook": "^0.8.0",
"eslint-plugin-testing-library": "^7.16.1",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
"fork-ts-checker-webpack-plugin": "^9.1.0",
@@ -337,13 +337,13 @@
"imports-loader": "^5.0.0",
"jest": "^30.3.0",
"jest-environment-jsdom": "^29.7.0",
"jest-html-reporter": "^4.3.0",
"jest-html-reporter": "^4.4.0",
"jest-websocket-mock": "^2.5.0",
"js-yaml-loader": "^1.2.2",
"jsdom": "^28.1.0",
"jsdom": "^29.0.2",
"lerna": "^9.0.4",
"lightningcss": "^1.32.0",
"mini-css-extract-plugin": "^2.10.1",
"mini-css-extract-plugin": "^2.10.2",
"open-cli": "^9.0.0",
"oxlint": "^1.56.0",
"po2json": "^0.4.5",
@@ -361,7 +361,7 @@
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.4.0",
"thread-loader": "^4.0.4",
"ts-jest": "^29.4.6",
"ts-jest": "^29.4.9",
"tscw-config": "^1.1.2",
"tsx": "^4.21.0",
"typescript": "5.4.5",

View File

@@ -29,8 +29,8 @@
},
"dependencies": {
"chalk": "^5.6.2",
"lodash-es": "^4.17.23",
"yeoman-generator": "^7.5.1",
"lodash-es": "^4.18.1",
"yeoman-generator": "^8.1.2",
"yosay": "^3.0.0"
},
"devDependencies": {

View File

@@ -75,7 +75,7 @@
"devDependencies": {
"@babel/cli": "^7.28.6",
"@babel/core": "^7.29.0",
"@babel/preset-env": "^7.29.0",
"@babel/preset-env": "^7.29.2",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"typescript": "^5.0.0",
@@ -102,7 +102,7 @@
"react-dom": "^17.0.2",
"react-loadable": "^5.5.0",
"tinycolor2": "*",
"lodash": "^4.17.21",
"lodash": "^4.18.1",
"antd": "^5.26.0",
"jed": "^1.1.1"
},

View File

@@ -26,11 +26,11 @@
"dependencies": {
"@apache-superset/core": "*",
"@types/react": "*",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"tinycolor2": "*"
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
"@ant-design/icons": "^5.6.1",
"@emotion/react": "^11.4.1",
"@superset-ui/core": "*",
"@testing-library/dom": "^8.20.1",

View File

@@ -21,7 +21,11 @@ import { styled, css } from '@apache-superset/core/theme';
export const ControlSubSectionHeader = styled.div`
${({ theme }) => css`
font-weight: ${theme.fontWeightStrong};
margin-top: ${theme.sizeUnit * 3}px;
margin-bottom: ${theme.sizeUnit}px;
font-size: ${theme.fontSizeSM}px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: ${theme.colorTextSecondary};
`}
`;

View File

@@ -24,39 +24,40 @@
"lib"
],
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@babel/runtime": "^7.29.2",
"@types/json-bigint": "^1.0.4",
"@visx/responsive": "^3.12.0",
"ace-builds": "^1.43.6",
"ag-grid-community": "35.0.1",
"ag-grid-react": "35.0.1",
"brace": "^0.11.1",
"classnames": "^2.5.1",
"csstype": "^3.2.3",
"core-js": "^3.49.0",
"csstype": "^3.2.3",
"d3-format": "^3.1.2",
"dayjs": "^1.11.20",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.20",
"dompurify": "^3.3.3",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
"jed": "^1.1.1",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"math-expression-evaluator": "^2.0.7",
"pretty-ms": "^9.3.0",
"re-resizable": "^6.11.2",
"react-ace": "^14.0.1",
"react-js-cron": "^5.2.0",
"react-draggable": "^4.5.0",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.1",
"react-ultimate-pagination": "^1.3.2",
"react-error-boundary": "6.0.0",
"react-js-cron": "^5.2.0",
"react-markdown": "^8.0.7",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.0",
"react-ultimate-pagination": "^1.3.2",
"regenerator-runtime": "^0.14.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
@@ -64,7 +65,6 @@
"reselect": "^5.1.1",
"rison": "^0.1.1",
"seedrandom": "^3.0.5",
"@visx/responsive": "^3.12.0",
"xss": "^1.0.15"
},
"devDependencies": {
@@ -74,12 +74,12 @@
"@types/d3-scale": "^2.1.1",
"@types/d3-time": "^3.0.4",
"@types/d3-time-format": "^4.0.3",
"@types/react-table": "^7.7.20",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/jquery": "^3.5.33",
"@types/lodash": "^4.17.24",
"@types/node": "^25.3.3",
"@types/node": "^25.5.0",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",
"@types/rison": "0.1.0",
"@types/seedrandom": "^3.0.8",
"fetch-mock": "^12.6.0",
@@ -88,7 +88,6 @@
"timezone-mock": "1.4.0"
},
"peerDependencies": {
"antd": "^5.26.0",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.14.1",
@@ -101,6 +100,7 @@
"@types/react-loadable": "*",
"@types/react-window": "^1.8.8",
"@types/tinycolor2": "*",
"antd": "^5.26.0",
"nanoid": "^5.0.9",
"react": "^17.0.2",
"react-dom": "^17.0.2",

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

@@ -283,6 +283,16 @@ export function AsyncAceEditor(
color: ${token.colorText} !important;
}
/* Fix cursor misalignment by ensuring consistent font-family */
.ace_editor .ace_content {
font-family: ${editorFontFamily} !important;
}
/* Ensure the text layer uses the same font-family */
.ace_editor .ace_text-layer {
font-family: ${editorFontFamily} !important;
}
/* Adjust gutter colors */
.ace_editor .ace_gutter {
background-color: ${token.colorBgElevated} !important;
@@ -309,6 +319,11 @@ export function AsyncAceEditor(
opacity: 0.5;
}
/* Style bracket matching to blend with theme */
.ace_editor .ace_bracket {
border-color: ${token.colorPrimaryBorderHover} !important;
}
/* Adjust cursor color */
.ace_editor .ace_cursor {
color: ${token.colorPrimaryText} !important;

View File

@@ -115,6 +115,7 @@ import {
PlusSquareOutlined,
PlusOutlined,
ProfileOutlined,
PushpinFilled,
PushpinOutlined,
QuestionCircleOutlined,
ReloadOutlined,
@@ -270,6 +271,7 @@ const AntdIcons = {
PlusSquareOutlined,
PlusOutlined,
ProfileOutlined,
PushpinFilled,
PushpinOutlined,
ReloadOutlined,
QuestionCircleOutlined,

View File

@@ -18,6 +18,9 @@
*/
// Specific modal implementations
export { ChartPropertiesModal } from './ChartPropertiesModal';
export { ConfirmDialog } from './ConfirmDialog';
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
export { DuplicateDatasetModal } from './DuplicateDatasetModal';
export { EditDatasetModal } from './EditDatasetModal';
export { ImportDatasetModal } from './ImportDatasetModal';

View File

@@ -47,7 +47,7 @@ export class ChartListPage {
}
/**
* Navigate to the chart list page.
* Navigate to the chart list page in table view.
* Forces table view via URL parameter to avoid card view default
* (ListviewsDefaultCardView feature flag may enable card view).
*/
@@ -55,6 +55,13 @@ export class ChartListPage {
await this.page.goto(`${URL.CHART_LIST}?viewMode=table`);
}
/**
* Navigate to the chart list page in card view.
*/
async gotoCardView(): Promise<void> {
await this.page.goto(`${URL.CHART_LIST}?viewMode=card`);
}
/**
* Wait for the table to load
* @param options - Optional wait options
@@ -63,6 +70,16 @@ export class ChartListPage {
await this.table.waitForVisible(options);
}
/**
* Wait for card view to finish loading.
*/
async waitForCardLoad(options?: { timeout?: number }): Promise<void> {
await this.page
.locator('[data-test="styled-card"]')
.first()
.waitFor({ state: 'visible', ...options });
}
/**
* Gets a chart row locator by name.
* Returns a Locator that tests can use with expect().toBeVisible(), etc.
@@ -129,4 +146,24 @@ export class ChartListPage {
async clickBulkAction(actionName: string): Promise<void> {
await this.bulkSelect.clickAction(actionName);
}
// --- Card view methods ---
/**
* Gets a chart card locator by name (card view).
*/
getChartCard(chartName: string): Locator {
return this.page
.locator('[data-test="styled-card"]')
.filter({ hasText: chartName });
}
/**
* Clicks the edit option in a chart card's dropdown menu (card view).
*/
async clickCardEditAction(chartName: string): Promise<void> {
const card = this.getChartCard(chartName);
await card.locator('[aria-label="more"]').click();
await this.page.locator('[data-test="chart-list-edit-option"]').click();
}
}

View File

@@ -17,21 +17,20 @@
* under the License.
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { ChartListPage } from '../../pages/ChartListPage';
import {
test as testWithAssets,
expect,
} from '../../../helpers/fixtures/testAssets';
import { ChartListPage } from '../../../pages/ChartListPage';
import { ChartPropertiesModal } from '../../../components/modals/ChartPropertiesModal';
import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal';
import { Toast } from '../../../components/core/Toast';
import { apiGetChart, ENDPOINTS } from '../../../helpers/api/chart';
ChartPropertiesModal,
DeleteConfirmationModal,
} from '../../components/modals';
import { Toast } from '../../components/core';
import { apiGetChart, ENDPOINTS } from '../../helpers/api/chart';
import { createTestChart } from './chart-test-helpers';
import { waitForGet, waitForPut } from '../../../helpers/api/intercepts';
import { waitForGet, waitForPut } from '../../helpers/api/intercepts';
import {
expectStatusOneOf,
expectValidExportZip,
} from '../../../helpers/api/assertions';
} from '../../helpers/api/assertions';
/**
* Extend testWithAssets with chartListPage navigation (beforeEach equivalent).
@@ -261,6 +260,60 @@ test('should bulk delete multiple charts', async ({
}
});
test('should edit chart name from card view', async ({ page, testAssets }) => {
// Create throwaway chart for editing
const { id: chartId, name: chartName } = await createTestChart(
page,
testAssets,
test.info(),
{ prefix: 'test_card_edit' },
);
// Navigate to card view (not table view)
const cardListPage = new ChartListPage(page);
await cardListPage.gotoCardView();
await cardListPage.waitForCardLoad();
// Verify chart card is visible
await expect(cardListPage.getChartCard(chartName)).toBeVisible();
// Open card dropdown and click edit
await cardListPage.clickCardEditAction(chartName);
// Wait for properties modal to be ready
const propertiesModal = new ChartPropertiesModal(page);
await propertiesModal.waitForReady();
// Edit the chart name
const newName = `card_renamed_${Date.now()}_${test.info().parallelIndex}`;
await propertiesModal.fillName(newName);
// Set up response intercept for save
const saveResponsePromise = waitForPut(page, `${ENDPOINTS.CHART}${chartId}`);
// Click Save button
await propertiesModal.clickSave();
// Wait for save to complete and verify success
expectStatusOneOf(await saveResponsePromise, [200, 201]);
// Modal should close
await propertiesModal.waitForHidden();
// Verify success toast appears
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify the renamed card appears in card view and old name is gone
await expect(cardListPage.getChartCard(newName)).toBeVisible();
await expect(cardListPage.getChartCard(chartName)).not.toBeVisible();
// Backend verification: API returns updated name
const response = await apiGetChart(page, chartId);
const chart = (await response.json()).result;
expect(chart.slice_name).toBe(newName);
});
test('should bulk export multiple charts', async ({
page,
chartListPage,

View File

@@ -18,9 +18,9 @@
*/
import type { Page, TestInfo } from '@playwright/test';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import { apiPostChart } from '../../../helpers/api/chart';
import { getDatasetByName } from '../../../helpers/api/dataset';
import type { TestAssets } from '../../helpers/fixtures';
import { apiPostChart } from '../../helpers/api/chart';
import { getDatasetByName } from '../../helpers/api/dataset';
interface TestChartResult {
id: number;

View File

@@ -17,28 +17,27 @@
* under the License.
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { DashboardListPage } from '../../pages/DashboardListPage';
import {
test as testWithAssets,
expect,
} from '../../../helpers/fixtures/testAssets';
import { DashboardListPage } from '../../../pages/DashboardListPage';
import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal';
import { ImportDatasetModal } from '../../../components/modals/ImportDatasetModal';
import { Toast } from '../../../components/core/Toast';
DeleteConfirmationModal,
ImportDatasetModal,
} from '../../components/modals';
import { Toast } from '../../components/core';
import {
apiGetDashboard,
apiDeleteDashboard,
apiExportDashboards,
getDashboardByName,
ENDPOINTS,
} from '../../../helpers/api/dashboard';
} from '../../helpers/api/dashboard';
import { createTestDashboard } from './dashboard-test-helpers';
import { waitForGet, waitForPost } from '../../../helpers/api/intercepts';
import { waitForGet, waitForPost } from '../../helpers/api/intercepts';
import {
expectStatusOneOf,
expectValidExportZip,
} from '../../../helpers/api/assertions';
import { TIMEOUT } from '../../../utils/constants';
} from '../../helpers/api/assertions';
import { TIMEOUT } from '../../utils/constants';
/**
* Extend testWithAssets with dashboardListPage navigation (beforeEach equivalent).

View File

@@ -18,8 +18,8 @@
*/
import type { Page, TestInfo } from '@playwright/test';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import { apiPostDashboard } from '../../../helpers/api/dashboard';
import type { TestAssets } from '../../helpers/fixtures';
import { apiPostDashboard } from '../../helpers/api/dashboard';
interface TestDashboardResult {
id: number;

View File

@@ -18,9 +18,9 @@
*/
import { test, expect } from '@playwright/test';
import { DashboardPage } from '../../../pages/DashboardPage';
import { Toast } from '../../../components/core';
import { TIMEOUT } from '../../../utils/constants';
import { DashboardPage } from '../../pages/DashboardPage';
import { Toast } from '../../components/core';
import { TIMEOUT } from '../../utils/constants';
/**
* Dashboard Export E2E tests.

View File

@@ -18,16 +18,16 @@
*/
import { test, expect } from '@playwright/test';
import { AuthPage } from '../../../pages/AuthPage';
import { DashboardPage } from '../../../pages/DashboardPage';
import { apiPostTheme, apiDeleteTheme } from '../../../helpers/api/theme';
import { AuthPage } from '../../pages/AuthPage';
import { DashboardPage } from '../../pages/DashboardPage';
import { apiPostTheme, apiDeleteTheme } from '../../helpers/api/theme';
import {
apiPostDashboard,
apiPutDashboard,
apiDeleteDashboard,
} from '../../../helpers/api/dashboard';
import { apiGet } from '../../../helpers/api/requests';
import { TIMEOUT } from '../../../utils/constants';
} from '../../helpers/api/dashboard';
import { apiGet } from '../../helpers/api/requests';
import { TIMEOUT } from '../../utils/constants';
/**
* Dashboard Theme E2E tests.

View File

@@ -17,17 +17,17 @@
* under the License.
*/
import { test, expect } from '../../../helpers/fixtures/testAssets';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import { testWithAssets as test, expect } from '../../helpers/fixtures';
import type { TestAssets } from '../../helpers/fixtures';
import type { Page, TestInfo } from '@playwright/test';
import { ExplorePage } from '../../../pages/ExplorePage';
import { CreateDatasetPage } from '../../../pages/CreateDatasetPage';
import { DatasetListPage } from '../../../pages/DatasetListPage';
import { ChartCreationPage } from '../../../pages/ChartCreationPage';
import { ENDPOINTS } from '../../../helpers/api/dataset';
import { waitForPost } from '../../../helpers/api/intercepts';
import { expectStatusOneOf } from '../../../helpers/api/assertions';
import { apiPostDatabase } from '../../../helpers/api/database';
import { ExplorePage } from '../../pages/ExplorePage';
import { CreateDatasetPage } from '../../pages/CreateDatasetPage';
import { DatasetListPage } from '../../pages/DatasetListPage';
import { ChartCreationPage } from '../../pages/ChartCreationPage';
import { ENDPOINTS } from '../../helpers/api/dataset';
import { waitForPost } from '../../helpers/api/intercepts';
import { expectStatusOneOf } from '../../helpers/api/assertions';
import { apiPostDatabase } from '../../helpers/api/database';
interface GsheetsSetupResult {
sheetName: string;

View File

@@ -17,37 +17,36 @@
* under the License.
*/
import {
test as testWithAssets,
expect,
} from '../../../helpers/fixtures/testAssets';
import { testWithAssets, expect } from '../../helpers/fixtures';
import path from 'path';
import { DatasetListPage } from '../../../pages/DatasetListPage';
import { ExplorePage } from '../../../pages/ExplorePage';
import { ConfirmDialog } from '../../../components/modals/ConfirmDialog';
import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal';
import { ImportDatasetModal } from '../../../components/modals/ImportDatasetModal';
import { DuplicateDatasetModal } from '../../../components/modals/DuplicateDatasetModal';
import { EditDatasetModal } from '../../../components/modals/EditDatasetModal';
import { Toast } from '../../../components/core/Toast';
import { DatasetListPage } from '../../pages/DatasetListPage';
import { ExplorePage } from '../../pages/ExplorePage';
import {
ConfirmDialog,
DeleteConfirmationModal,
DuplicateDatasetModal,
EditDatasetModal,
ImportDatasetModal,
} from '../../components/modals';
import { Toast } from '../../components/core';
import {
apiDeleteDataset,
apiGetDataset,
apiPostVirtualDataset,
getDatasetByName,
ENDPOINTS,
} from '../../../helpers/api/dataset';
} from '../../helpers/api/dataset';
import { createTestDataset } from './dataset-test-helpers';
import {
waitForGet,
waitForPost,
waitForPut,
} from '../../../helpers/api/intercepts';
} from '../../helpers/api/intercepts';
import {
expectStatusOneOf,
expectValidExportZip,
} from '../../../helpers/api/assertions';
import { TIMEOUT } from '../../../utils/constants';
} from '../../helpers/api/assertions';
import { TIMEOUT } from '../../utils/constants';
/**
* Extend testWithAssets with datasetListPage navigation (beforeEach equivalent).
@@ -458,11 +457,12 @@ test.describe('import dataset', () => {
testAssets,
}) => {
// Dataset name from fixture (test_netflix_1768502050965)
// Note: Fixture contains a Google Sheets dataset - test will skip if gsheets connector unavailable
// Note: Fixture contains a Google Sheets dataset backed by shillelagh[gsheetsapi],
// which is a base dependency — import failure fails the test hard (no skip).
const importedDatasetName = 'test_netflix_1768502050965';
const fixturePath = path.resolve(
__dirname,
'../../../fixtures/dataset_export.zip',
'../../fixtures/dataset_export.zip',
);
// Cleanup: Delete any existing dataset with the same name from previous runs
@@ -518,25 +518,12 @@ test.describe('import dataset', () => {
importResponse = await importResponsePromise;
}
// Check final import response for gsheets connector errors
// Fail hard if dataset import fails.
// The fixture contains a gsheets dataset; shillelagh[gsheetsapi] is a base
// dependency (pyproject.toml), so the engine is always available in CI.
if (!importResponse.ok()) {
const errorBody = await importResponse.json().catch(() => ({}));
const errorText = JSON.stringify(errorBody);
// Skip test if gsheets connector not installed
if (
errorText.includes('gsheets') ||
errorText.includes('No such DB engine') ||
errorText.includes('Could not load database driver')
) {
await test.info().attach('skip-reason', {
body: `Import failed due to missing gsheets connector: ${errorText}`,
contentType: 'text/plain',
});
test.skip();
return;
}
// Re-throw other errors
throw new Error(`Import failed: ${errorText}`);
throw new Error(`Import failed: ${JSON.stringify(errorBody)}`);
}
// Modal should close on success

View File

@@ -18,8 +18,8 @@
*/
import type { Page, TestInfo } from '@playwright/test';
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
import { createTestVirtualDataset } from '../../../helpers/api/dataset';
import type { TestAssets } from '../../helpers/fixtures';
import { createTestVirtualDataset } from '../../helpers/api/dataset';
interface TestDatasetResult {
id: number;

View File

@@ -21,6 +21,7 @@
import d3 from 'd3';
import { extent as d3Extent } from 'd3-array';
import {
ValueFormatter,
getNumberFormatter,
getSequentialSchemeRegistry,
CategoricalColorNamespace,
@@ -60,7 +61,8 @@ interface CountryMapProps {
height: number;
country: string;
linearColorScheme: string;
numberFormat: string;
numberFormat?: string; // left for backward compatibility
formatter: ValueFormatter;
colorScheme: string;
sliceId: number;
}
@@ -74,13 +76,12 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
height,
country,
linearColorScheme,
numberFormat,
formatter,
colorScheme,
sliceId,
} = props;
const container = element;
const format = getNumberFormatter(numberFormat);
const rawExtents = d3Extent(data, v => v.metric);
const extents: [number, number] =
rawExtents[0] != null && rawExtents[1] != null
@@ -182,7 +183,7 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
.style('top', `${position[1] + 30}px`)
.style('left', `${position[0]}px`)
.html(
`<div><strong>${getNameOfRegion(d)}</strong><br>${result.length > 0 ? format(result[0].metric) : ''}</div>`,
`<div><strong>${getNameOfRegion(d)}</strong><br>${result.length > 0 ? formatter(result[0].metric) : ''}</div>`,
);
};

View File

@@ -69,6 +69,7 @@ const config: ControlPanelConfig = {
},
},
],
['currency_format'],
['linear_color_scheme'],
],
},

View File

@@ -16,26 +16,48 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ChartProps } from '@superset-ui/core';
import { ChartProps, getValueFormatter } from '@superset-ui/core';
export default function transformProps(chartProps: ChartProps) {
const { width, height, formData, queriesData } = chartProps;
const { width, height, formData, queriesData, datasource } = chartProps;
const {
linearColorScheme,
numberFormat,
currencyFormat,
selectCountry,
colorScheme,
sliceId,
metric,
} = formData;
const {
currencyFormats = {},
columnFormats = {},
currencyCodeColumn,
} = datasource;
const { data, detected_currency: detectedCurrency } = queriesData[0];
const formatter = getValueFormatter(
metric,
currencyFormats,
columnFormats,
numberFormat,
currencyFormat,
undefined, // key - not needed for single-metric charts
data,
currencyCodeColumn,
detectedCurrency,
);
return {
width,
height,
data: queriesData[0].data,
country: selectCountry ? String(selectCountry).toLowerCase() : null,
linearColorScheme,
numberFormat,
numberFormat, // left for backward compatibility
colorScheme,
sliceId,
formatter,
};
}

View File

@@ -93,6 +93,7 @@ describe('CountryMap (legacy d3)', () => {
linearColorScheme="bnbColors"
colorScheme=""
numberFormat=".2f"
formatter={jest.fn().mockReturnValue('100')}
/>,
);
@@ -115,6 +116,7 @@ describe('CountryMap (legacy d3)', () => {
country="canada"
linearColorScheme="bnbColors"
colorScheme=""
formatter={jest.fn().mockReturnValue('100')}
/>,
);
@@ -144,6 +146,7 @@ describe('CountryMap (legacy d3)', () => {
country="canada"
linearColorScheme="bnbColors"
colorScheme=""
formatter={jest.fn().mockReturnValue('100')}
/>,
);

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

@@ -32,7 +32,7 @@
"d3": "^3.5.17",
"d3-tip": "^0.9.1",
"fast-safe-stringify": "^2.1.1",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"nvd3-fork": "^2.0.5",
"dompurify": "^3.3.3",
"prop-types": "^15.8.1",

View File

@@ -28,15 +28,15 @@
"@types/react-table": "^7.7.20",
"classnames": "^2.5.1",
"d3-array": "^3.2.4",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"memoize-one": "^5.2.1",
"react-table": "^7.8.0",
"regenerator-runtime": "^0.14.1",
"xss": "^1.0.15"
},
"peerDependencies": {
"@ant-design/icons": "^5.6.1",
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/dom": "^8.20.1",

View File

@@ -31,14 +31,14 @@
"dependencies": {
"@types/geojson": "^7946.0.16",
"geojson": "^0.5.0",
"lodash": "^4.17.23"
"lodash": "^4.18.1"
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
"@ant-design/icons": "^5.6.1",
"@apache-superset/core": "*",
"@reduxjs/toolkit": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"@types/react-redux": "*",
"geostyler": "^18.3.1",
"geostyler-data": "^1.0.0",

View File

@@ -28,7 +28,7 @@
"@types/react-redux": "^7.1.34",
"acorn": "^8.16.0",
"d3-array": "^3.2.4",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"zod": "^4.3.6"
},
"peerDependencies": {

View File

@@ -659,7 +659,10 @@ export default function transformProps(
for (const s of series) {
if (s.id) {
const columnsArr = labelMap[s.id];
(s as any).stack = columnsArr[idxSelectedDimension];
const dimensionValue = columnsArr?.[idxSelectedDimension];
if (dimensionValue !== undefined) {
(s as any).stack = dimensionValue;
}
}
}
}
@@ -682,9 +685,24 @@ export default function transformProps(
// For horizontal bar charts, set max/min from calculated data bounds
if (shouldCalculateDataBounds) {
// Set max to actual data max to avoid gaps and ensure labels are visible
if (dataMax !== undefined && yAxisMax === undefined) {
yAxisMax = dataMax;
// For stacked charts, clamp against the per-row stacked total to avoid
// clipping bars. Also keep dataMax so that mixed-sign stacks (where
// positive and negative values cancel in the algebraic row sum) cannot
// produce an axis max smaller than the largest individual positive segment.
const stackedTotalMax = Math.max(
...sortedTotalValues.filter(
(v): v is number => typeof v === 'number' && !Number.isNaN(v),
),
);
const effectiveDataMax = stack
? Math.max(dataMax ?? Number.NEGATIVE_INFINITY, stackedTotalMax)
: dataMax;
if (
effectiveDataMax !== undefined &&
Number.isFinite(effectiveDataMax) &&
yAxisMax === undefined
) {
yAxisMax = effectiveDataMax;
}
// Set min to actual data min for diverging bars
if (dataMin !== undefined && yAxisMin === undefined && dataMin < 0) {

View File

@@ -24,6 +24,7 @@ import {
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { supersetTheme } from '@apache-superset/core/theme';
import { StackControlsValue } from '../../../src/constants';
import type {
GridComponentOption,
LegendComponentOption,
@@ -727,6 +728,97 @@ describe('Bar Chart X-axis Time Formatting', () => {
});
});
describe('Horizontal stacked bar chart axis bounds', () => {
// Dataset where each series max = 4 but stacked total max = 8
const stackedData: ChartDataResponseResult[] = [
createTestQueryData(
[
{ team: 'Team A', High: 2, Low: 2, Medium: 4 },
{ team: 'Team B', High: null, Low: null, Medium: 3 },
{ team: 'Team C', High: null, Low: null, Medium: 1 },
],
{
colnames: ['team', 'High', 'Low', 'Medium'],
coltypes: [
GenericDataType.String,
GenericDataType.Numeric,
GenericDataType.Numeric,
GenericDataType.Numeric,
],
},
),
];
const horizontalStackedFormData: EchartsTimeseriesFormData = {
...(baseFormData as EchartsTimeseriesFormData),
x_axis: 'team',
metric: ['High', 'Low', 'Medium'],
groupby: [],
orientation: OrientationType.Horizontal,
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: StackControlsValue.Stack,
truncateYAxis: true,
};
test('xAxis.max uses stacked total, not individual series max', () => {
// Individual series max = 4 (Medium), stacked total for Team A = 8
// Without the fix, xAxis.max would be 4, clipping bars and duplicating labels
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
>({
defaultFormData: horizontalStackedFormData,
defaultVizType: 'echarts_timeseries_bar',
defaultQueriesData: stackedData,
});
const { echartOptions } = transformProps(chartProps);
const xAxis = echartOptions.xAxis as any;
// xAxis.max must be >= stacked total (8), not capped at individual series max (4)
expect(xAxis.max).toBeGreaterThanOrEqual(8);
});
test('xAxis.max is not set to individual series max when stacking', () => {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
>({
defaultFormData: horizontalStackedFormData,
defaultVizType: 'echarts_timeseries_bar',
defaultQueriesData: stackedData,
});
const { echartOptions } = transformProps(chartProps);
const xAxis = echartOptions.xAxis as any;
// 4 is the individual series max — the axis should not be clipped there
expect(xAxis.max).not.toBe(4);
});
test('non-stacked horizontal bar chart still uses individual series max', () => {
const nonStackedFormData: EchartsTimeseriesFormData = {
...horizontalStackedFormData,
stack: null,
};
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
>({
defaultFormData: nonStackedFormData,
defaultVizType: 'echarts_timeseries_bar',
defaultQueriesData: stackedData,
});
const { echartOptions } = transformProps(chartProps);
const xAxis = echartOptions.xAxis as any;
// Without stacking, xAxis.max should be based on individual series values
expect(xAxis.max).toBe(4);
});
});
describe('Legend layout regressions', () => {
const getBottomLegendLayout = (
chartWidth: number,

View File

@@ -37,7 +37,7 @@
"@superset-ui/core": "*",
"ace-builds": "^1.4.14",
"handlebars": "^4.7.8",
"lodash": "^4.17.11",
"lodash": "^4.18.1",
"dayjs": "^1.11.19",
"react": "^17.0.2",
"react-ace": "^10.1.0",

View File

@@ -30,10 +30,12 @@ import { debounceFunc } from '../../consts';
interface StyleCustomControlProps {
value: string;
htmlSanitization: boolean;
}
const StyleControl = (props: CustomControlConfig<StyleCustomControlProps>) => {
const theme = useTheme();
const htmlSanitization = props.htmlSanitization ?? true;
const defaultValue = props?.value
? undefined
@@ -48,10 +50,16 @@ const StyleControl = (props: CustomControlConfig<StyleCustomControlProps>) => {
<ControlHeader>
<div>
{props.label}
<InfoTooltip
iconStyle={{ marginLeft: theme.sizeUnit }}
tooltip={t('You need to configure HTML sanitization to use CSS')}
/>
{htmlSanitization && (
<InfoTooltip
iconStyle={{ marginLeft: theme.sizeUnit }}
tooltip={t(
'CSS styles may be removed by server-side HTML sanitization. ' +
'If styles are not applying, ask your Superset administrator ' +
'to adjust the HTML sanitization configuration.',
)}
/>
)}
</div>
</ControlHeader>
<CodeEditor
@@ -79,8 +87,9 @@ export const styleControlSetItem: ControlSetItem = {
valueKey: null,
validators: [],
mapStateToProps: ({ controls }) => ({
mapStateToProps: ({ controls, common }) => ({
value: controls?.handlebars_template?.value,
htmlSanitization: common?.conf?.HTML_SANITIZATION ?? true,
}),
},
};

View File

@@ -27,12 +27,11 @@
"access": "public"
},
"peerDependencies": {
"react-icons": "5.4.0",
"@ant-design/icons": "^5.6.1",
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"lodash": "^4.17.11",
"lodash": "^4.18.1",
"prop-types": "*",
"react": "^17.0.2",
"react-dom": "^17.0.2"

View File

@@ -22,9 +22,11 @@ import { safeHtmlSpan } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { supersetTheme } from '@apache-superset/core/theme';
import PropTypes from 'prop-types';
import { FaSort } from 'react-icons/fa';
import { FaSortDown as FaSortDesc } from 'react-icons/fa';
import { FaSortUp as FaSortAsc } from 'react-icons/fa';
import {
CaretUpOutlined,
CaretDownOutlined,
ColumnHeightOutlined,
} from '@ant-design/icons';
import {
ColorFormatters,
getTextColorForBackground,
@@ -855,7 +857,7 @@ export class TableRenderer extends Component<
if (activeSortColumn !== key) {
return (
<FaSort
<ColumnHeightOutlined
onClick={() =>
this.sortData(key, visibleColKeys, pivotData, maxRowIndex)
}
@@ -863,7 +865,8 @@ export class TableRenderer extends Component<
);
}
const SortIcon = sortingOrder[key] === 'asc' ? FaSortAsc : FaSortDesc;
const SortIcon =
sortingOrder[key] === 'asc' ? CaretUpOutlined : CaretDownOutlined;
return (
<SortIcon
onClick={() =>
@@ -873,7 +876,9 @@ export class TableRenderer extends Component<
);
};
const headerCellFormattedValue =
dateFormatters?.[attrName]?.(convertToNumberIfNumeric(colKey[attrIdx])) ?? colKey[attrIdx];
dateFormatters?.[attrName]?.(
convertToNumberIfNumeric(colKey[attrIdx]),
) ?? colKey[attrIdx];
const { backgroundColor, color } = getCellColor(
[attrName],
headerCellFormattedValue,

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

@@ -24,20 +24,19 @@
"lib"
],
"dependencies": {
"react-icons": "5.4.0",
"@types/d3-array": "^3.2.2",
"@types/react-table": "^7.7.20",
"classnames": "^2.5.1",
"d3-array": "^3.2.4",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"memoize-one": "^5.2.1",
"react-table": "^7.8.0",
"regenerator-runtime": "^0.14.1",
"xss": "^1.0.15"
},
"peerDependencies": {
"@ant-design/icons": "^5.6.1",
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/dom": "^8.20.1",

View File

@@ -35,9 +35,11 @@ import {
Row,
} from 'react-table';
import { extent as d3Extent, max as d3Max } from 'd3-array';
import { FaSort } from 'react-icons/fa';
import { FaSortDown as FaSortDesc } from 'react-icons/fa';
import { FaSortUp as FaSortAsc } from 'react-icons/fa';
import {
CaretUpOutlined,
CaretDownOutlined,
ColumnHeightOutlined,
} from '@ant-design/icons';
import cx from 'classnames';
import {
DataRecord,
@@ -221,9 +223,9 @@ function cellBackground({
function SortIcon<D extends object>({ column }: { column: ColumnInstance<D> }) {
const { isSorted, isSortedDesc } = column;
let sortIcon = <FaSort />;
let sortIcon = <ColumnHeightOutlined />;
if (isSorted) {
sortIcon = isSortedDesc ? <FaSortDesc /> : <FaSortAsc />;
sortIcon = isSortedDesc ? <CaretDownOutlined /> : <CaretUpOutlined />;
}
return sortIcon;
}

View File

@@ -34,9 +34,9 @@
"d3-scale": "^4.0.2"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"@types/lodash": "*",
"@types/react": "*",
"react": "^17.0.2"

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