Compare commits
19 Commits
feat/plugi
...
engine-man
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75a64b3062 | ||
|
|
9c5e6d187b | ||
|
|
3aad565eab | ||
|
|
b7b59dfb8a | ||
|
|
fa0d4e1c08 | ||
|
|
08df7d5178 | ||
|
|
9dc54d8f1b | ||
|
|
e2ce534148 | ||
|
|
b3f8831d34 | ||
|
|
1775cae220 | ||
|
|
26f0390bbb | ||
|
|
ea27cabfc6 | ||
|
|
f39367bffd | ||
|
|
11395531f2 | ||
|
|
ec018cd842 | ||
|
|
48d3f441b8 | ||
|
|
b3393c65f7 | ||
|
|
8776b651a5 | ||
|
|
ccd32920fc |
10
.github/workflows/claude.yml
vendored
@@ -17,12 +17,13 @@ jobs:
|
||||
steps:
|
||||
- name: Check if user is allowed
|
||||
id: check
|
||||
env:
|
||||
COMMENTER: ${{ github.event.comment.user.login }}
|
||||
run: |
|
||||
# List of allowed users
|
||||
ALLOWED_USERS="mistercrunch,rusackas"
|
||||
|
||||
# Get the commenter's username
|
||||
COMMENTER="${{ github.event.comment.user.login }}"
|
||||
|
||||
echo "Checking permissions for user: $COMMENTER"
|
||||
|
||||
# Check if user is in allowed list
|
||||
@@ -44,12 +45,9 @@ jobs:
|
||||
steps:
|
||||
- name: Comment access denied
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
COMMENTER_LOGIN: ${{ github.event.comment.user.login || github.event.review.user.login || github.event.issue.user.login }}
|
||||
with:
|
||||
script: |
|
||||
const commenter = process.env.COMMENTER_LOGIN;
|
||||
const message = `👋 Hi @${commenter}!
|
||||
const message = `👋 Hi @${{ github.event.comment.user.login || github.event.review.user.login || github.event.issue.user.login }}!
|
||||
|
||||
Thanks for trying to use Claude Code, but currently only certain team members have access to this feature.
|
||||
|
||||
|
||||
4
.github/workflows/codeql-analysis.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -53,6 +53,6 @@ jobs:
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
89
.github/workflows/country-map-build-regen.yml
vendored
@@ -1,89 +0,0 @@
|
||||
# Re-runs the Country Map plugin's build pipeline whenever its YAML
|
||||
# configs change, then opens a PR with the updated GeoJSON outputs.
|
||||
# Verifies the pipeline stays reproducible and surfaces cartographic
|
||||
# diffs to maintainers as legible GeoJSON, not opaque notebook JSON.
|
||||
|
||||
name: country-map / regenerate outputs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'superset-frontend/plugins/plugin-chart-country-map/scripts/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
regen:
|
||||
name: Regenerate GeoJSON + manifest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install build script deps
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyyaml
|
||||
|
||||
- name: Run unit tests for build transforms
|
||||
run: |
|
||||
cd superset-frontend/plugins/plugin-chart-country-map/scripts
|
||||
python -m unittest test_build -v
|
||||
|
||||
- name: Run build pipeline
|
||||
run: |
|
||||
cd superset-frontend/plugins/plugin-chart-country-map/scripts
|
||||
./build.sh
|
||||
|
||||
- name: Detect output drift
|
||||
id: drift
|
||||
run: |
|
||||
if [ -n "$(git status --porcelain superset/static/assets/country-maps/ \
|
||||
superset-frontend/plugins/plugin-chart-country-map/src/data/manifest.json)" ]; then
|
||||
echo "drift=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Outputs differ from committed snapshot:"
|
||||
git status --short superset/static/assets/country-maps/
|
||||
else
|
||||
echo "drift=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Outputs match committed snapshot — nothing to do."
|
||||
fi
|
||||
|
||||
- name: Comment on PR if drift
|
||||
if: steps.drift.outputs.drift == 'true' && github.event_name == 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
gh pr comment "$PR_NUMBER" --repo "${{ github.repository }}" --body \
|
||||
"⚠️ Country Map: drift detected between locally committed GeoJSON outputs and CI-regenerated outputs. Either the YAML configs were updated without re-running \`./scripts/build.sh\`, OR mapshaper output differs cross-platform (macOS dev vs Linux CI). Investigate before merge."
|
||||
|
||||
# Informational only: cross-platform mapshaper output reproducibility
|
||||
# is still being worked through. Don't block PRs on drift; surface
|
||||
# via the comment above and a workflow summary.
|
||||
- name: Summarize drift (informational)
|
||||
if: steps.drift.outputs.drift == 'true' && github.event_name == 'pull_request'
|
||||
run: |
|
||||
{
|
||||
echo "## Country Map: output drift detected (informational)";
|
||||
echo "";
|
||||
echo "Files differing from committed snapshot:";
|
||||
echo '```';
|
||||
git status --short superset/static/assets/country-maps/ \
|
||||
superset-frontend/plugins/plugin-chart-country-map/src/data/manifest.json;
|
||||
echo '```';
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
2
.github/workflows/ephemeral-env-pr-close.yml
vendored
@@ -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@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
|
||||
uses: aws-actions/amazon-ecr-login@19d944daaa35f0fa1d3f7f8af1d3f2e5de25c5b7 # v2
|
||||
|
||||
- name: Delete ECR image tag
|
||||
if: steps.describe-services.outputs.active == 'true'
|
||||
|
||||
4
.github/workflows/ephemeral-env.yml
vendored
@@ -199,7 +199,7 @@ jobs:
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
|
||||
uses: aws-actions/amazon-ecr-login@19d944daaa35f0fa1d3f7f8af1d3f2e5de25c5b7 # 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@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
|
||||
uses: aws-actions/amazon-ecr-login@19d944daaa35f0fa1d3f7f8af1d3f2e5de25c5b7 # v2
|
||||
|
||||
- name: Check target image exists in ECR
|
||||
id: check-image
|
||||
|
||||
2
.github/workflows/latest-release-tag.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
- name: Run latest-tag
|
||||
uses: ./.github/actions/latest-tag
|
||||
if: steps.latest-tag.outputs.SKIP_TAG != 'true'
|
||||
if: (! ${{ steps.latest-tag.outputs.SKIP_TAG }} )
|
||||
with:
|
||||
description: Superset latest release
|
||||
tag-name: latest
|
||||
|
||||
10
.github/workflows/superset-docs-deploy.yml
vendored
@@ -17,16 +17,6 @@ on:
|
||||
|
||||
workflow_dispatch: {}
|
||||
|
||||
# Serialize deploys: the action pushes to apache/superset-site without
|
||||
# rebasing, so concurrent runs race on the final push and the loser fails
|
||||
# with `! [rejected] asf-site -> asf-site (fetch first)`. Cancel any
|
||||
# in-progress run as soon as a newer one starts — the destination repo
|
||||
# isn't touched until the final push step, so canceling mid-build is safe,
|
||||
# and the freshest content always wins.
|
||||
concurrency:
|
||||
group: docs-deploy-asf-site
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
config:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
6
.gitignore
vendored
@@ -70,14 +70,8 @@ superset-websocket/config.json
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
superset/static/*
|
||||
!superset/static/assets/
|
||||
superset/static/assets/*
|
||||
!superset/static/assets/.gitkeep
|
||||
# Country Map plugin's generated GeoJSON outputs are committed so a
|
||||
# fresh ephemeral env can render the chart without first running the
|
||||
# build pipeline. See superset/static/assets/country-maps/README.md.
|
||||
!superset/static/assets/country-maps/
|
||||
!superset/static/assets/country-maps/**
|
||||
superset/static/uploads/*
|
||||
!superset/static/uploads/.gitkeep
|
||||
yarn-error.log
|
||||
|
||||
@@ -50,7 +50,7 @@ repos:
|
||||
hooks:
|
||||
- id: check-docstring-first
|
||||
- id: check-added-large-files
|
||||
exclude: ^.*\.(geojson)$|^.*\.geo\.json$|^docs/static/img/screenshots/.*|^superset-frontend/CHANGELOG\.md$|^superset/examples/.*/data\.parquet$
|
||||
exclude: ^.*\.(geojson)$|^docs/static/img/screenshots/.*|^superset-frontend/CHANGELOG\.md$|^superset/examples/.*/data\.parquet$
|
||||
- id: check-yaml
|
||||
exclude: ^helm/superset/templates/
|
||||
- id: debug-statements
|
||||
|
||||
@@ -56,33 +56,8 @@ def verify_sha512(filename: str) -> str:
|
||||
# Part 2: Verify RSA key - this is the same as running `gpg --verify {release}.asc {release}` and comparing the RSA key and email address against the KEYS file # noqa: E501
|
||||
|
||||
|
||||
KEYS_URL = "https://downloads.apache.org/superset/KEYS"
|
||||
|
||||
|
||||
def ensure_keys_imported() -> None:
|
||||
"""Import the Apache Superset KEYS file into the local GPG keyring.
|
||||
|
||||
Without this, `gpg --verify` returns "No public key" and the signature
|
||||
cannot actually be verified — only the key ID in the signature metadata
|
||||
is visible.
|
||||
"""
|
||||
try:
|
||||
keys = requests.get(KEYS_URL, timeout=30)
|
||||
except requests.RequestException as exc:
|
||||
print(f"Warning: could not fetch KEYS file for import: {exc}")
|
||||
return
|
||||
if keys.status_code != 200:
|
||||
print(f"Warning: could not fetch KEYS file (HTTP {keys.status_code})")
|
||||
return
|
||||
subprocess.run( # noqa: S603
|
||||
["gpg", "--import"], # noqa: S607
|
||||
input=keys.content,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
def get_gpg_info(filename: str) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Run the GPG verify command and extract RSA/EDDSA key and email address."""
|
||||
"""Run the GPG verify command and extract RSA key and email address."""
|
||||
asc_filename = filename + ".asc"
|
||||
result = subprocess.run( # noqa: S603
|
||||
["gpg", "--verify", asc_filename, filename], # noqa: S607
|
||||
@@ -90,50 +65,25 @@ def get_gpg_info(filename: str) -> tuple[Optional[str], Optional[str]]:
|
||||
)
|
||||
output = result.stderr.decode()
|
||||
|
||||
# If no public key was available, import KEYS and retry so that
|
||||
# `Good signature from "Name <email>"` appears in the output.
|
||||
if "No public key" in output:
|
||||
ensure_keys_imported()
|
||||
result = subprocess.run( # noqa: S603
|
||||
["gpg", "--verify", asc_filename, filename], # noqa: S607
|
||||
capture_output=True, # noqa: S607
|
||||
)
|
||||
output = result.stderr.decode()
|
||||
|
||||
rsa_key = re.search(r"RSA key ([0-9A-F]+)", output)
|
||||
eddsa_key = re.search(r"EDDSA key ([0-9A-F]+)", output)
|
||||
|
||||
# Try multiple patterns — `Good signature from` is the most reliable
|
||||
# source of the email; `issuer` is a fallback for older gpg output.
|
||||
email_patterns = (
|
||||
r'Good signature from ".*?<([^>]+)>"',
|
||||
r'aka ".*?<([^>]+)>"',
|
||||
r'issuer "([^"]+)"',
|
||||
)
|
||||
email_result: Optional[str] = None
|
||||
for pattern in email_patterns:
|
||||
match = re.search(pattern, output)
|
||||
if match:
|
||||
email_result = match.group(1)
|
||||
break
|
||||
email = re.search(r'issuer "([^"]+)"', output)
|
||||
|
||||
rsa_key_result = rsa_key.group(1) if rsa_key else None
|
||||
eddsa_key_result = eddsa_key.group(1) if eddsa_key else None
|
||||
email_result = email.group(1) if email else None
|
||||
|
||||
key_result = rsa_key_result or eddsa_key_result
|
||||
|
||||
# Debugging:
|
||||
if key_result:
|
||||
print("RSA or EDDSA Key found")
|
||||
else:
|
||||
print("Warning: No RSA or EDDSA key found in GPG verification output.")
|
||||
if email_result:
|
||||
print(f"Email found: {email_result}")
|
||||
print("email found")
|
||||
else:
|
||||
print("Warning: No email address found in GPG verification output.")
|
||||
if "No public key" in output:
|
||||
print(
|
||||
"Hint: public key is not in your keyring. Import it with:\n"
|
||||
f" curl -s {KEYS_URL} | gpg --import"
|
||||
)
|
||||
|
||||
return key_result, email_result
|
||||
|
||||
|
||||
40
UPDATING.md
@@ -24,28 +24,23 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### Country Map plugin redesigned (legacy plugin deprecated)
|
||||
### `SSH_TUNNEL_MANAGER_CLASS` replaced by `ENGINE_MANAGER_CLASS`
|
||||
|
||||
A new Country Map chart plugin (`@superset-ui/plugin-chart-country-map`, `viz_type='country_map_v2'`) replaces the legacy plugin (`@superset-ui/legacy-plugin-chart-country-map`, `viz_type='country_map'`). The legacy plugin is still installed and functional — existing dashboards continue to render unchanged — but is now badged as deprecated in the chart-type picker, with the display name "Country Map (Legacy)".
|
||||
The `SSH_TUNNEL_MANAGER_CLASS` config setting, the `superset.extensions.ssh` module (containing `SSHManager` and `SSHManagerFactory`), and the `ssh_manager_factory` extension singleton have been removed. SQLAlchemy engine creation — including SSH tunnel construction and URL rewriting — is now centralized in `EngineManager` (`superset/engines/manager.py`), wired up via `EngineManagerExtension` (`superset/extensions/engine_manager.py`).
|
||||
|
||||
**What changed:**
|
||||
- Modern `chart/data` endpoint instead of `explore_json` (full async / caching / semantic-layer integration)
|
||||
- Configurable per-deployment + per-chart **worldview** for disputed regions (default ships Natural Earth's `_ukr` worldview, which shows Crimea as Ukrainian and aligns with broadly-expected positions on Kosovo, Western Sahara, Palestine, Cyprus, Kashmir; configurable via `superset_config.COUNTRY_MAP.default_worldview`)
|
||||
- Both **Admin 0** (countries) **and Admin 1** (subdivisions) supported in one plugin (subsumes years of bespoke per-country submissions like French departments, Italian regions, Türkiye city map)
|
||||
- New **Aggregated regions** admin level (Türkiye NUTS-1, France/Italy/Philippines administrative regions)
|
||||
- New **composite maps** (e.g. France with overseas territories combining mainland + DROMs + 6 sister Admin 0 records)
|
||||
- Per-chart **region include/exclude**, **flying-islands toggle**, **name-language selector**
|
||||
- Build pipeline replaces the legacy Jupyter notebook with `mapshaper`-CLI-based reproducible scripts + 5 declarative YAML configs in `superset-frontend/plugins/plugin-chart-country-map/scripts/`
|
||||
A new config setting, `ENGINE_MANAGER_CLASS` (default: `"superset.engines.manager.EngineManager"`), replaces `SSH_TUNNEL_MANAGER_CLASS` as the customization hook. Deployments that previously subclassed `SSHManager` (e.g. for bastion routing, audit logging, host-key policy, or custom credential handling) should subclass `EngineManager` instead and set `ENGINE_MANAGER_CLASS` to the dotted path of the subclass. Override the relevant methods:
|
||||
|
||||
**Migration behavior:**
|
||||
- Existing charts using `viz_type='country_map'` continue to render against the legacy plugin. No DB migrations needed.
|
||||
- The legacy plugin's chart picker entry shows the standard Deprecation badge with an explanation pointing at the new chart type.
|
||||
- Users can re-create existing charts using the modern plugin manually; an automated "Switch to new chart" button is planned.
|
||||
- The legacy plugin (and its committed `src/countries/*.geojson` assets) will be removed in a future major release once existing dashboards have migrated.
|
||||
| Old `SSHManager` method | New override point on `EngineManager` |
|
||||
|---|---|
|
||||
| `__init__(app)` reading `SSH_TUNNEL_*` configs | `__init__` — the same `SSH_TUNNEL_LOCAL_BIND_ADDRESS`, `SSH_TUNNEL_TIMEOUT_SEC`, and `SSH_TUNNEL_PACKET_TIMEOUT_SEC` configs are still loaded by `EngineManagerExtension.init_app` and passed in |
|
||||
| `create_tunnel(ssh_tunnel, uri)` | `_get_tunnel_kwargs(ssh_tunnel, uri)` for parameter construction and `_create_tunnel(ssh_tunnel, uri)` for the `sshtunnel.open_tunnel` + `start()` call |
|
||||
| `build_sqla_url(url, server)` | Inlined in `get_engine` as `uri.set(host=tunnel.local_bind_address[0], port=tunnel.local_bind_port)` |
|
||||
|
||||
**For maintainers / power users with custom modifications:**
|
||||
- The legacy plugin's Jupyter notebook (`scripts/Country Map GeoJSON Generator.ipynb`) is no longer the source of truth. Local touchups should be ported to the new plugin's YAML configs (`scripts/config/{name_overrides,flying_islands,territory_assignments,regional_aggregations,composite_maps}.yaml`) or, for genuine edge cases that don't fit YAML, to the `scripts/procedural/` escape-hatch directory.
|
||||
- See `superset-frontend/plugins/plugin-chart-country-map/SIP_DRAFT.md` for the full design rationale.
|
||||
**Behavioral note:** the old `SSHManager.create_tunnel` passed `debug_level=logging.getLogger("flask_appbuilder").level` to `sshtunnel.open_tunnel`. The new `_get_tunnel_kwargs` does not. Subclasses relying on that should add it back in their override.
|
||||
|
||||
### `Database.get_sqla_engine(nullpool=...)` deprecated
|
||||
|
||||
The `nullpool` keyword argument to `Database.get_sqla_engine` is deprecated and ignored — the engine manager always uses `NullPool`. The kwarg is still accepted (with a `DeprecationWarning`) so external callers passing `nullpool=False` won't fail with `TypeError`, but the resulting engine will use `NullPool` regardless. Remove the argument from your callers; it will be deleted in a future release.
|
||||
|
||||
### Granular Export Controls
|
||||
|
||||
@@ -137,7 +132,6 @@ DISTRIBUTED_COORDINATION_CONFIG = {
|
||||
```
|
||||
|
||||
See `superset/config.py` for complete configuration options.
|
||||
|
||||
### WebSocket config for GAQ with Docker
|
||||
|
||||
[35896](https://github.com/apache/superset/pull/35896) and [37624](https://github.com/apache/superset/pull/37624) updated documentation on how to run and configure Superset with Docker. Specifically for the WebSocket configuration, a new `docker/superset-websocket/config.example.json` was added to the repo, so that users could copy it to create a `docker/superset-websocket/config.json` file. The existing `docker/superset-websocket/config.json` was removed and git-ignored, so if you're using GAQ / WebSocket make sure to:
|
||||
@@ -351,7 +345,7 @@ Note: Pillow is now a required dependency (previously optional) to support image
|
||||
- [33116](https://github.com/apache/superset/pull/33116) In Echarts Series charts (e.g. Line, Area, Bar, etc.) charts, the `x_axis_sort_series` and `x_axis_sort_series_ascending` form data items have been renamed with `x_axis_sort` and `x_axis_sort_asc`.
|
||||
There's a migration added that can potentially affect a significant number of existing charts.
|
||||
- [32317](https://github.com/apache/superset/pull/32317) The horizontal filter bar feature is now out of testing/beta development and its feature flag `HORIZONTAL_FILTER_BAR` has been removed.
|
||||
- [31590](https://github.com/apache/superset/pull/31590) Marks the beginning of intricate work around supporting dynamic Theming, and breaks support for [THEME_OVERRIDES](https://github.com/apache/superset/blob/732de4ac7fae88e29b7f123b6cbb2d7cd411b0e4/superset/config.py#L671) in favor of a new theming system based on AntD V5. Likely this will be in disrepair until settling over the 5.x lifecycle.
|
||||
- [31590](https://github.com/apache/superset/pull/31590) Marks the begining of intricate work around supporting dynamic Theming, and breaks support for [THEME_OVERRIDES](https://github.com/apache/superset/blob/732de4ac7fae88e29b7f123b6cbb2d7cd411b0e4/superset/config.py#L671) in favor of a new theming system based on AntD V5. Likely this will be in disrepair until settling over the 5.x lifecycle.
|
||||
- [32432](https://github.com/apache/superset/pull/32432) Moves the List Roles FAB view to the frontend and requires `FAB_ADD_SECURITY_API` to be enabled in the configuration and `superset init` to be executed.
|
||||
- [34319](https://github.com/apache/superset/pull/34319) Drill to Detail and Drill By is now supported in Embedded mode, and also with the `DASHBOARD_RBAC` FF. If you don't want to expose these features in Embedded / `DASHBOARD_RBAC`, make sure the roles used for Embedded / `DASHBOARD_RBAC`don't have the required permissions to perform D2D actions.
|
||||
|
||||
@@ -366,7 +360,7 @@ Note: Pillow is now a required dependency (previously optional) to support image
|
||||
- [31774](https://github.com/apache/superset/pull/31774): Fixes the spelling of the `USE-ANALAGOUS-COLORS` feature flag. Please update any scripts/configuration item to use the new/corrected `USE-ANALOGOUS-COLORS` flag spelling.
|
||||
- [31582](https://github.com/apache/superset/pull/31582) Removed the legacy Area, Bar, Event Flow, Heatmap, Histogram, Line, Sankey, and Sankey Loop charts. They were all automatically migrated to their ECharts counterparts with the exception of the Event Flow and Sankey Loop charts which were removed as they were not actively maintained and not widely used. If you were using the Event Flow or Sankey Loop charts, you will need to find an alternative solution.
|
||||
- [31198](https://github.com/apache/superset/pull/31198) Disallows by default the use of the following ClickHouse functions: "version", "currentDatabase", "hostName".
|
||||
- [29798](https://github.com/apache/superset/pull/29798) Since 3.1.0, the initial schedule for an alert or report was mistakenly offset by the specified timezone's relation to UTC. The initial schedule should now begin at the correct time.
|
||||
- [29798](https://github.com/apache/superset/pull/29798) Since 3.1.0, the intial schedule for an alert or report was mistakenly offset by the specified timezone's relation to UTC. The initial schedule should now begin at the correct time.
|
||||
- [30021](https://github.com/apache/superset/pull/30021) The `dev` layer in our Dockerfile no long includes firefox binaries, only Chromium to reduce bloat/docker-build-time.
|
||||
- [30099](https://github.com/apache/superset/pull/30099) Translations are no longer included in the default docker image builds. If your environment requires translations, you'll want to set the docker build arg `BUILD_TRANSLATIONS=true`.
|
||||
- [31262](https://github.com/apache/superset/pull/31262) NOTE: deprecated `pylint` in favor of `ruff` as our only python linter. Only affect development workflows positively (not the release itself). It should cover most important rules, be much faster, but some things linting rules that were enforced before may not be enforce in the exact same way as before.
|
||||
@@ -379,7 +373,7 @@ Note: Pillow is now a required dependency (previously optional) to support image
|
||||
- [25166](https://github.com/apache/superset/pull/25166) Changed the default configuration of `UPLOAD_FOLDER` from `/app/static/uploads/` to `/static/uploads/`. It also removed the unused `IMG_UPLOAD_FOLDER` and `IMG_UPLOAD_URL` configuration options.
|
||||
- [30284](https://github.com/apache/superset/pull/30284) Deprecated GLOBAL_ASYNC_QUERIES_REDIS_CONFIG in favor of the new GLOBAL_ASYNC_QUERIES_CACHE_BACKEND configuration. To leverage Redis Sentinel, set CACHE_TYPE to RedisSentinelCache, or use RedisCache for standalone Redis
|
||||
- [31961](https://github.com/apache/superset/pull/31961) Upgraded React from version 16.13.1 to 17.0.2. If you are using custom frontend extensions or plugins, you may need to update them to be compatible with React 17.
|
||||
- [31260](https://github.com/apache/superset/pull/31260) Docker images now use `uv pip install` instead of `pip install` to manage the python environment. Most docker-based deployments will be affected, whether you derive one of the published images, or have custom bootstrap script that install python libraries (drivers)
|
||||
- [31260](https://github.com/apache/superset/pull/31260) Docker images now use `uv pip install` instead of `pip install` to manage the python envrionment. Most docker-based deployments will be affected, whether you derive one of the published images, or have custom bootstrap script that install python libraries (drivers)
|
||||
|
||||
### Potential Downtime
|
||||
|
||||
@@ -456,7 +450,7 @@ Note: Pillow is now a required dependency (previously optional) to support image
|
||||
- [26462](https://github.com/apache/superset/issues/26462): Removes the Profile feature given that it's not actively maintained and not widely used.
|
||||
- [26377](https://github.com/apache/superset/pull/26377): Removes the deprecated Redirect API that supported short URLs used before the permalink feature.
|
||||
- [26329](https://github.com/apache/superset/issues/26329): Removes the deprecated `DASHBOARD_NATIVE_FILTERS` feature flag. The previous value of the feature flag was `True` and now the feature is permanently enabled.
|
||||
- [25510](https://github.com/apache/superset/pull/25510): Reinforces that any newly defined Python data format (other than epoch) must adhere to the ISO 8601 standard (enforced by way of validation at the API and database level) after a previous relaxation to include slashes in addition to dashes. From now on when specifying new columns, dataset owners will need to use a SQL expression instead to convert their string columns of the form %Y/%m/%d etc. to a `DATE`, `DATETIME`, etc. type.
|
||||
- [25510](https://github.com/apache/superset/pull/25510): Reenforces that any newly defined Python data format (other than epoch) must adhere to the ISO 8601 standard (enforced by way of validation at the API and database level) after a previous relaxation to include slashes in addition to dashes. From now on when specifying new columns, dataset owners will need to use a SQL expression instead to convert their string columns of the form %Y/%m/%d etc. to a `DATE`, `DATETIME`, etc. type.
|
||||
- [26372](https://github.com/apache/superset/issues/26372): Removes the deprecated `GENERIC_CHART_AXES` feature flag. The previous value of the feature flag was `True` and now the feature is permanently enabled.
|
||||
|
||||
### Potential Downtime
|
||||
|
||||
@@ -81,87 +81,6 @@ SLACK_CACHE_TIMEOUT = int(timedelta(days=2).total_seconds())
|
||||
SLACK_API_RATE_LIMIT_RETRY_COUNT = 5
|
||||
```
|
||||
|
||||
### Webhook integration
|
||||
|
||||
Superset can send alert and report notifications to any HTTP endpoint — useful for chat platforms, incident management tools, or custom automation.
|
||||
|
||||
#### Enabling Webhooks
|
||||
|
||||
Enable the feature flag in `superset_config.py`:
|
||||
|
||||
```python
|
||||
FEATURE_FLAGS = {
|
||||
"ALERT_REPORTS": True,
|
||||
"ALERT_REPORT_WEBHOOK": True,
|
||||
}
|
||||
```
|
||||
|
||||
#### Configuring a Webhook Recipient
|
||||
|
||||
When creating or editing an alert or report, select **Webhook** as the notification method and enter your endpoint URL.
|
||||
|
||||
#### Payload Format
|
||||
|
||||
Superset sends an HTTP POST with `Content-Type: application/json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Alert",
|
||||
"header": {
|
||||
"notification_format": "JSON",
|
||||
"notification_type": "Alert",
|
||||
"notification_source": "Alert",
|
||||
"chart_id": 42,
|
||||
"dashboard_id": null
|
||||
},
|
||||
"text": "Alert condition met: value exceeded threshold",
|
||||
"description": "Monthly revenue dropped below target",
|
||||
"url": "https://your-superset-host/superset/dashboard/1/"
|
||||
}
|
||||
```
|
||||
|
||||
When a report includes file attachments (CSV, PDF, or PNG screenshots), the request is sent as `multipart/form-data` instead. In that case, each top-level payload field (`name`, `text`, `description`, `url`) becomes its own form field, and nested structures like `header` are serialized as a JSON-encoded string in their own field. Every attachment is added as a repeated form field named `files`:
|
||||
|
||||
```
|
||||
POST /webhook HTTP/1.1
|
||||
Content-Type: multipart/form-data; boundary=...
|
||||
|
||||
--...
|
||||
Content-Disposition: form-data; name="name"
|
||||
|
||||
My Alert
|
||||
--...
|
||||
Content-Disposition: form-data; name="header"
|
||||
|
||||
{"notification_format": "JSON", "notification_type": "Alert", ...}
|
||||
--...
|
||||
Content-Disposition: form-data; name="text"
|
||||
|
||||
Alert condition met: value exceeded threshold
|
||||
--...
|
||||
Content-Disposition: form-data; name="files"; filename="report.csv"
|
||||
Content-Type: text/csv
|
||||
|
||||
<file bytes>
|
||||
--...
|
||||
```
|
||||
|
||||
Webhook consumers should branch on `Content-Type`: parse the body as JSON when `application/json`, or read the individual form fields (decoding `header` as JSON) when `multipart/form-data`.
|
||||
|
||||
#### HTTPS Enforcement
|
||||
|
||||
To require HTTPS webhook URLs (recommended for production), set:
|
||||
|
||||
```python
|
||||
ALERT_REPORTS_WEBHOOK_HTTPS_ONLY = True
|
||||
```
|
||||
|
||||
When enabled, Superset rejects webhook configurations that use `http://` URLs.
|
||||
|
||||
#### Retry Behavior
|
||||
|
||||
Superset automatically retries webhook deliveries on `429 Too Many Requests` and `5xx` server errors using exponential backoff.
|
||||
|
||||
### Kubernetes-specific
|
||||
|
||||
- You must have a `celery beat` pod running. If you're using the chart included in the GitHub repository under [helm/superset](https://github.com/apache/superset/tree/master/helm/superset), you need to put `supersetCeleryBeat.enabled = true` in your values override.
|
||||
|
||||
@@ -472,38 +472,6 @@ FEATURE_FLAGS = {
|
||||
|
||||
A current list of feature flags can be found in the [Feature Flags](/admin-docs/configuration/feature-flags) documentation.
|
||||
|
||||
## Security Configuration
|
||||
|
||||
### HASH_ALGORITHM
|
||||
|
||||
Controls the hashing algorithm used for internal checksums and cache keys (thumbnails, cache keys, etc.). The default is `sha256`, which satisfies environments with stricter compliance requirements (e.g., FedRAMP). Set it to `md5` to retain the legacy behavior from older Superset deployments:
|
||||
|
||||
```python
|
||||
HASH_ALGORITHM = "sha256" # default; set to "md5" for legacy behavior
|
||||
```
|
||||
|
||||
A companion `HASH_ALGORITHM_FALLBACKS` list (default: `["md5"]`) lets UUID lookups fall back to older algorithms, which enables gradual migration without breaking existing entries. Set it to `[]` for strict mode (use only `HASH_ALGORITHM`).
|
||||
|
||||
:::note
|
||||
This setting affects internal Superset operations only, not user passwords or authentication tokens. Changing it in an existing deployment may invalidate cached values but does not require a database migration.
|
||||
:::
|
||||
|
||||
## SQL Lab Query History Pruning
|
||||
|
||||
SQL Lab query history is stored in the metadata database and is **not** pruned by default. To trim older rows, enable the `prune_query` Celery beat task by uncommenting it in `CELERY_BEAT_SCHEDULE` and choosing a retention window:
|
||||
|
||||
```python
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
"prune_query": {
|
||||
"task": "prune_query",
|
||||
"schedule": crontab(minute=0, hour=0, day_of_month=1),
|
||||
"kwargs": {"retention_period_days": 180},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Adjust `retention_period_days` to control how long query rows are kept. Companion opt-in tasks (`prune_logs`, `prune_tasks`) exist for pruning the logs and tasks tables; see the commented-out examples in `superset/config.py`. Without enabling these tasks, the metadata database will grow unbounded over time.
|
||||
|
||||
:::resources
|
||||
- [Blog: Feature Flags in Apache Superset](https://preset.io/blog/feature-flags-in-apache-superset-and-preset/)
|
||||
:::
|
||||
|
||||
@@ -122,17 +122,6 @@ When `ENABLE_UI_THEME_ADMINISTRATION = True`:
|
||||
3. Administrators can change system themes without restarting Superset
|
||||
4. Configuration file themes serve as fallbacks when no UI themes are set
|
||||
|
||||
### Theme Validation and Fallback
|
||||
|
||||
Superset validates theme JSON when it is saved, either through the UI or via configuration. If a theme contains invalid tokens or an unrecognized structure, Superset logs a warning and falls back to the built-in default theme rather than applying a broken configuration. This prevents a bad theme from rendering the application unusable.
|
||||
|
||||
The fallback order is:
|
||||
1. **UI-configured system theme** (highest priority, if `ENABLE_UI_THEME_ADMINISTRATION = True`)
|
||||
2. **`THEME_DEFAULT` / `THEME_DARK`** from `superset_config.py`
|
||||
3. **Built-in Superset default theme** (always present as a safety net)
|
||||
|
||||
If you see unexpected styling after a config change, check the Superset server logs for theme validation warnings.
|
||||
|
||||
### Copying Themes Between Systems
|
||||
|
||||
To export a theme for use in configuration files or another instance:
|
||||
@@ -154,11 +143,7 @@ Superset supports custom fonts through the theme configuration, allowing you to
|
||||
|
||||
### Default Fonts
|
||||
|
||||
By default, Superset uses **Inter** for UI text and **IBM Plex Mono** for code (SQL editors, JSON fields, and other monospace contexts). Both fonts are bundled with the application via `@fontsource` packages and work offline without any external network calls.
|
||||
|
||||
:::note
|
||||
IBM Plex Mono replaced Fira Code as the default code font in Superset 6.1. If you have an existing theme that explicitly sets `fontFamilyCode: "Fira Code, ..."`, you may want to update it.
|
||||
:::
|
||||
By default, Superset uses Inter and Fira Code fonts which are bundled with the application via `@fontsource` packages. These fonts work offline and require no external network calls.
|
||||
|
||||
### Configuring Custom Fonts
|
||||
|
||||
|
||||
@@ -205,57 +205,6 @@ FAB_ADD_SECURITY_API = True
|
||||
|
||||
Once configured, the documentation for additional "Security" endpoints will be visible in Swagger for you to explore.
|
||||
|
||||
### API Key Authentication
|
||||
|
||||
Superset supports long-lived API keys for service accounts, CI/CD pipelines, and programmatic integrations (including MCP clients).
|
||||
|
||||
#### Enabling API Key Authentication
|
||||
|
||||
API key authentication is **disabled by default**. To turn it on, set the Flask-AppBuilder config value in `superset_config.py` and also enable the matching feature flag so the management UI is exposed:
|
||||
|
||||
```python
|
||||
FAB_API_KEY_ENABLED = True
|
||||
|
||||
FEATURE_FLAGS = {
|
||||
"FAB_API_KEY_ENABLED": True,
|
||||
}
|
||||
```
|
||||
|
||||
The config value registers the `ApiKeyApi` blueprint on the backend; the feature flag controls whether the UI for managing keys appears for the user. See the [Feature Flags](/admin-docs/configuration/feature-flags) documentation for more on feature flag configuration.
|
||||
|
||||
#### Creating an API Key
|
||||
|
||||
Once enabled, each user manages their own keys from their profile page:
|
||||
|
||||
1. Open the user menu (top-right) and click **Info** to navigate to the User Info page
|
||||
2. Expand the **API Keys** section
|
||||
3. Click **+ API Key**
|
||||
4. Enter a name and (optionally) an expiration date
|
||||
5. Copy the generated token — it is shown only once
|
||||
|
||||
Only users with the `can_read` and `can_write` permissions on `ApiKey` (granted by default to Admins) can manage API keys.
|
||||
|
||||
#### Using an API Key
|
||||
|
||||
Pass the key as a Bearer token in the `Authorization` header:
|
||||
|
||||
```
|
||||
Authorization: Bearer <your-api-key>
|
||||
```
|
||||
|
||||
This works for all REST API endpoints and the MCP server. The request is executed with the permissions of the user who created the key.
|
||||
|
||||
#### Use Cases
|
||||
|
||||
- **CI/CD pipelines** — automated chart/dashboard exports and imports
|
||||
- **MCP integrations** — connect AI assistants without interactive login
|
||||
- **External services** — dashboards embedded in other applications
|
||||
- **Service accounts** — long-lived credentials that don't expire with session cookies
|
||||
|
||||
:::caution
|
||||
Store API keys securely. Anyone with a valid key can make requests on behalf of the creating user. Revoke keys promptly if they are compromised by deleting them from the **API Keys** section of your User Info page.
|
||||
:::
|
||||
|
||||
### Customizing Permissions
|
||||
|
||||
The permissions exposed by FAB are very granular and allow for a great level of
|
||||
|
||||
@@ -69,8 +69,8 @@
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.33",
|
||||
"antd": "^6.3.7",
|
||||
"baseline-browser-mapping": "^2.10.29",
|
||||
"caniuse-lite": "^1.0.30001792",
|
||||
"baseline-browser-mapping": "^2.10.27",
|
||||
"caniuse-lite": "^1.0.30001791",
|
||||
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.0.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
@@ -97,8 +97,8 @@
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/react": "^19.1.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.59.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.52.0",
|
||||
"@typescript-eslint/parser": "^8.59.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
@@ -106,7 +106,7 @@
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"typescript-eslint": "^8.59.1",
|
||||
"webpack": "^5.106.2"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -141,47 +141,6 @@ def eval_node(node):
|
||||
return "<f-string>"
|
||||
return None
|
||||
|
||||
def static_return_bool(func_node):
|
||||
"""
|
||||
Statically resolve a method's return value to a bool when possible.
|
||||
|
||||
Returns True/False for functions whose body is (effectively) a single
|
||||
\`return True\` / \`return False\` — allowing a leading docstring and
|
||||
ignoring pure-comment/pass statements. Returns None for anything more
|
||||
complex (conditional returns, computed values, no return, etc.).
|
||||
|
||||
Used by \`has_implicit_cancel\` handling: \`diagnose()\` in lib.py calls
|
||||
the method and checks the return value, so an override that explicitly
|
||||
returns False must NOT be treated as enabling query cancelation.
|
||||
"""
|
||||
returns = []
|
||||
other_logic = False
|
||||
docstring_skipped = False
|
||||
for stmt in func_node.body:
|
||||
# Skip docstring (only the FIRST expression statement that is a
|
||||
# string constant — later bare string literals are not docstrings
|
||||
# and should count as non-trivial logic).
|
||||
if (not docstring_skipped
|
||||
and isinstance(stmt, ast.Expr)
|
||||
and isinstance(stmt.value, ast.Constant)
|
||||
and isinstance(stmt.value.value, str)):
|
||||
docstring_skipped = True
|
||||
continue
|
||||
if isinstance(stmt, ast.Pass):
|
||||
continue
|
||||
if isinstance(stmt, ast.Return):
|
||||
returns.append(stmt)
|
||||
continue
|
||||
# Any other statement (if/for/assign/etc.) means control flow is
|
||||
# non-trivial; bail out to be conservative.
|
||||
other_logic = True
|
||||
break
|
||||
if other_logic or len(returns) != 1:
|
||||
return None
|
||||
val = eval_node(returns[0].value)
|
||||
return val if isinstance(val, bool) else None
|
||||
|
||||
|
||||
def deep_merge(base, override):
|
||||
"""Deep merge two dictionaries. Override values take precedence."""
|
||||
if base is None:
|
||||
@@ -227,55 +186,8 @@ if not os.path.isdir(specs_dir):
|
||||
print(json.dumps({"error": f"Directory not found: {specs_dir}", "cwd": os.getcwd()}))
|
||||
sys.exit(1)
|
||||
|
||||
# Capability flag attributes with their defaults from BaseEngineSpec
|
||||
CAP_ATTR_DEFAULTS = {
|
||||
'supports_dynamic_schema': False,
|
||||
'supports_catalog': False,
|
||||
'supports_dynamic_catalog': False,
|
||||
'disable_ssh_tunneling': False,
|
||||
'supports_file_upload': True,
|
||||
'allows_joins': True,
|
||||
'allows_subqueries': True,
|
||||
}
|
||||
|
||||
# Maps source capability attribute -> output field name used in databases.json.
|
||||
# When a cap attr is assigned an unevaluable expression (e.g.
|
||||
# allows_joins = is_feature_enabled("DRUID_JOINS")), the JS layer uses this
|
||||
# mapping to preserve the corresponding field from the previously-generated
|
||||
# JSON rather than silently inheriting an incorrect parent default.
|
||||
CAP_ATTR_TO_OUTPUT_FIELD = {
|
||||
'allows_joins': 'joins',
|
||||
'allows_subqueries': 'subqueries',
|
||||
'supports_dynamic_schema': 'supports_dynamic_schema',
|
||||
'supports_catalog': 'supports_catalog',
|
||||
'supports_dynamic_catalog': 'supports_dynamic_catalog',
|
||||
'disable_ssh_tunneling': 'ssh_tunneling',
|
||||
'supports_file_upload': 'supports_file_upload',
|
||||
}
|
||||
|
||||
# Methods that indicate a capability when overridden by a non-BaseEngineSpec class.
|
||||
# Mirrors the has_custom_method checks in superset/db_engine_specs/lib.py.
|
||||
# cancel_query / has_implicit_cancel -> query_cancelation
|
||||
# (diagnose() checks cancel_query override OR has_implicit_cancel() == True;
|
||||
# base has_implicit_cancel returns False, so overriding it is the static
|
||||
# equivalent of that method returning True. get_cancel_query_id is NOT
|
||||
# part of the diagnose() heuristic and is intentionally excluded.)
|
||||
# estimate_statement_cost / estimate_query_cost -> query_cost_estimation
|
||||
# impersonate_user / update_impersonation_config / get_url_for_impersonation -> user_impersonation
|
||||
# validate_sql -> sql_validation (not used yet; validation is engine-based)
|
||||
CAP_METHODS = {
|
||||
'cancel_query', 'has_implicit_cancel',
|
||||
'estimate_statement_cost', 'estimate_query_cost',
|
||||
'impersonate_user', 'update_impersonation_config', 'get_url_for_impersonation',
|
||||
'validate_sql',
|
||||
}
|
||||
|
||||
# Only the literal BaseEngineSpec is excluded from method-override tracking.
|
||||
# Intermediate base classes (e.g. PrestoBaseEngineSpec) do count as overrides.
|
||||
TRUE_BASE_CLASS = 'BaseEngineSpec'
|
||||
|
||||
# First pass: collect all class info (name, bases, metadata, cap_attrs, direct_methods)
|
||||
class_info = {} # class_name -> {bases: [], metadata: {}, engine_name: str, filename: str, ...}
|
||||
# First pass: collect all class info (name, bases, metadata)
|
||||
class_info = {} # class_name -> {bases: [], metadata: {}, engine_name: str, filename: str}
|
||||
|
||||
for filename in sorted(os.listdir(specs_dir)):
|
||||
if not filename.endswith('.py') or filename in ('__init__.py', 'lib.py', 'lint_metadata.py'):
|
||||
@@ -306,54 +218,30 @@ for filename in sorted(os.listdir(specs_dir)):
|
||||
|
||||
# Extract class attributes
|
||||
engine_name = None
|
||||
engine_attr = None
|
||||
metadata = None
|
||||
cap_attrs = {} # capability flag attributes defined directly in this class
|
||||
# Cap attrs assigned via expressions we can't statically resolve
|
||||
# (e.g. is_feature_enabled("FLAG")). Tracked so the JS layer can
|
||||
# fall back to the previously-generated databases.json value
|
||||
# rather than inherit a parent default that would be wrong.
|
||||
unresolved_cap_attrs = set()
|
||||
direct_methods = set() # capability methods defined directly in this class
|
||||
|
||||
for item in node.body:
|
||||
if isinstance(item, ast.Assign):
|
||||
for target in item.targets:
|
||||
if not isinstance(target, ast.Name):
|
||||
continue
|
||||
if target.id == 'engine_name':
|
||||
val = eval_node(item.value)
|
||||
if isinstance(val, str):
|
||||
engine_name = val
|
||||
elif target.id == 'engine':
|
||||
val = eval_node(item.value)
|
||||
if isinstance(val, str):
|
||||
engine_attr = val
|
||||
elif target.id == 'metadata':
|
||||
metadata = eval_node(item.value)
|
||||
elif target.id in CAP_ATTR_DEFAULTS:
|
||||
val = eval_node(item.value)
|
||||
if isinstance(val, bool):
|
||||
cap_attrs[target.id] = val
|
||||
else:
|
||||
# Unevaluable expression — defer to JS fallback.
|
||||
unresolved_cap_attrs.add(target.id)
|
||||
elif isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
if item.name in CAP_METHODS:
|
||||
# has_implicit_cancel is special: diagnose() uses the
|
||||
# method's RETURN VALUE, not just its presence. If the
|
||||
# override statically returns False, treat it as if
|
||||
# the method weren't overridden so query_cancelation
|
||||
# matches diagnose(). Unresolvable / True / anything
|
||||
# else falls through as an override (conservative).
|
||||
if item.name == 'has_implicit_cancel':
|
||||
if static_return_bool(item) is False:
|
||||
continue
|
||||
direct_methods.add(item.name)
|
||||
if isinstance(target, ast.Name):
|
||||
if target.id == 'engine_name':
|
||||
val = eval_node(item.value)
|
||||
if isinstance(val, str):
|
||||
engine_name = val
|
||||
elif target.id == 'metadata':
|
||||
metadata = eval_node(item.value)
|
||||
|
||||
# Check for engine attribute with non-empty value to distinguish
|
||||
# true base classes from product classes like OceanBaseEngineSpec
|
||||
has_non_empty_engine = engine_attr is not None and bool(engine_attr)
|
||||
has_non_empty_engine = False
|
||||
for item in node.body:
|
||||
if isinstance(item, ast.Assign):
|
||||
for target in item.targets:
|
||||
if isinstance(target, ast.Name) and target.id == 'engine':
|
||||
# Check if engine value is non-empty string
|
||||
if isinstance(item.value, ast.Constant):
|
||||
has_non_empty_engine = bool(item.value.value)
|
||||
break
|
||||
|
||||
# True base classes: end with BaseEngineSpec AND don't define engine
|
||||
# or have empty engine (like PostgresBaseEngineSpec with engine = "")
|
||||
@@ -366,18 +254,13 @@ for filename in sorted(os.listdir(specs_dir)):
|
||||
'bases': base_names,
|
||||
'metadata': metadata,
|
||||
'engine_name': engine_name,
|
||||
'engine': engine_attr,
|
||||
'filename': filename,
|
||||
'is_base_or_mixin': is_true_base,
|
||||
'cap_attrs': cap_attrs,
|
||||
'unresolved_cap_attrs': unresolved_cap_attrs,
|
||||
'direct_methods': direct_methods,
|
||||
}
|
||||
except Exception as e:
|
||||
errors.append(f"{filename}: {str(e)}")
|
||||
|
||||
# Second pass: resolve inheritance and build final metadata + capability flags
|
||||
|
||||
# Second pass: resolve inheritance and build final metadata
|
||||
def get_inherited_metadata(class_name, visited=None):
|
||||
"""Recursively get metadata from parent classes."""
|
||||
if visited is None:
|
||||
@@ -403,64 +286,6 @@ def get_inherited_metadata(class_name, visited=None):
|
||||
|
||||
return inherited
|
||||
|
||||
def get_resolved_caps(class_name, visited=None):
|
||||
"""
|
||||
Resolve capability flags and method overrides with inheritance.
|
||||
|
||||
Returns (attr_values, unresolved, methods):
|
||||
- attr_values: {attr: bool} for attrs where the nearest MRO assignment
|
||||
was a literal bool. Defaults are applied at the call site.
|
||||
- unresolved: attrs where the nearest MRO assignment was an unevaluable
|
||||
expression (e.g. is_feature_enabled("FLAG")). The JS layer falls
|
||||
back to the previously-generated JSON value for these.
|
||||
- methods: capability methods defined directly in some non-base ancestor,
|
||||
matching the has_custom_method() logic in db_engine_specs/lib.py.
|
||||
|
||||
attr_values and unresolved are disjoint — an attr is in at most one.
|
||||
"""
|
||||
if visited is None:
|
||||
visited = set()
|
||||
if class_name in visited:
|
||||
return {}, set(), set()
|
||||
visited.add(class_name)
|
||||
|
||||
info = class_info.get(class_name)
|
||||
if not info:
|
||||
return {}, set(), set()
|
||||
|
||||
attr_values = {}
|
||||
unresolved = set()
|
||||
resolved_methods = set()
|
||||
|
||||
# Collect from parents, iterating right-to-left so leftmost bases win
|
||||
# (matches Python MRO: for class C(A, B), A's attributes take precedence).
|
||||
for base_name in reversed(info['bases']):
|
||||
p_vals, p_unres, p_meth = get_resolved_caps(base_name, visited.copy())
|
||||
# A parent's literal assignments overwrite whatever we inherited so far.
|
||||
for attr, val in p_vals.items():
|
||||
attr_values[attr] = val
|
||||
unresolved.discard(attr)
|
||||
# A parent's unresolved assignments likewise take precedence.
|
||||
for attr in p_unres:
|
||||
unresolved.add(attr)
|
||||
attr_values.pop(attr, None)
|
||||
resolved_methods.update(p_meth)
|
||||
|
||||
# Apply this class's own assignments (override parents).
|
||||
for attr, val in info['cap_attrs'].items():
|
||||
attr_values[attr] = val
|
||||
unresolved.discard(attr)
|
||||
for attr in info['unresolved_cap_attrs']:
|
||||
unresolved.add(attr)
|
||||
attr_values.pop(attr, None)
|
||||
|
||||
# Accumulate method overrides, but skip the literal BaseEngineSpec
|
||||
# (its implementations are stubs; only non-base overrides count).
|
||||
if class_name != TRUE_BASE_CLASS:
|
||||
resolved_methods.update(info['direct_methods'])
|
||||
|
||||
return attr_values, unresolved, resolved_methods
|
||||
|
||||
for class_name, info in class_info.items():
|
||||
# Skip base classes and mixins
|
||||
if info['is_base_or_mixin']:
|
||||
@@ -485,14 +310,7 @@ for class_name, info in class_info.items():
|
||||
|
||||
if final_metadata and isinstance(final_metadata, dict) and display_name:
|
||||
debug_info["classes_with_metadata"] += 1
|
||||
|
||||
# Resolve capability flags from Python source
|
||||
attr_values, unresolved_caps, cap_methods = get_resolved_caps(class_name)
|
||||
cap_attrs = dict(CAP_ATTR_DEFAULTS)
|
||||
cap_attrs.update(attr_values)
|
||||
engine_attr = info.get('engine') or ''
|
||||
|
||||
entry = {
|
||||
databases[display_name] = {
|
||||
'engine': display_name.lower().replace(' ', '_'),
|
||||
'engine_name': display_name,
|
||||
'module': info['filename'][:-3], # Remove .py extension
|
||||
@@ -500,40 +318,19 @@ for class_name, info in class_info.items():
|
||||
'time_grains': {},
|
||||
'score': 0,
|
||||
'max_score': 0,
|
||||
# Capability flags read from engine spec class attributes/methods
|
||||
'joins': cap_attrs['allows_joins'],
|
||||
'subqueries': cap_attrs['allows_subqueries'],
|
||||
'supports_dynamic_schema': cap_attrs['supports_dynamic_schema'],
|
||||
'supports_catalog': cap_attrs['supports_catalog'],
|
||||
'supports_dynamic_catalog': cap_attrs['supports_dynamic_catalog'],
|
||||
'ssh_tunneling': not cap_attrs['disable_ssh_tunneling'],
|
||||
'supports_file_upload': cap_attrs['supports_file_upload'],
|
||||
# Method-based flags: True only when a non-base class overrides them.
|
||||
# Matches diagnose() in lib.py: cancel_query override OR
|
||||
# has_implicit_cancel() returning True (which, given the base
|
||||
# returns False, is equivalent to overriding has_implicit_cancel).
|
||||
'query_cancelation': bool({'cancel_query', 'has_implicit_cancel'} & cap_methods),
|
||||
'query_cost_estimation': bool({'estimate_statement_cost', 'estimate_query_cost'} & cap_methods),
|
||||
# SQL validation is implemented in external validator classes keyed by engine name
|
||||
'sql_validation': engine_attr in {'presto', 'postgresql'},
|
||||
'user_impersonation': bool(
|
||||
{'impersonate_user', 'update_impersonation_config', 'get_url_for_impersonation'} & cap_methods
|
||||
),
|
||||
'joins': True,
|
||||
'subqueries': True,
|
||||
'supports_dynamic_schema': False,
|
||||
'supports_catalog': False,
|
||||
'supports_dynamic_catalog': False,
|
||||
'ssh_tunneling': False,
|
||||
'query_cancelation': False,
|
||||
'supports_file_upload': False,
|
||||
'user_impersonation': False,
|
||||
'query_cost_estimation': False,
|
||||
'sql_validation': False,
|
||||
}
|
||||
|
||||
# Tell the JS layer which output fields were populated from the
|
||||
# BaseEngineSpec default because the source assignment was an
|
||||
# unevaluable expression; those get overridden from existing JSON.
|
||||
unresolved_fields = sorted(
|
||||
CAP_ATTR_TO_OUTPUT_FIELD[attr]
|
||||
for attr in unresolved_caps
|
||||
if attr in CAP_ATTR_TO_OUTPUT_FIELD
|
||||
)
|
||||
if unresolved_fields:
|
||||
entry['_unresolved_cap_fields'] = unresolved_fields
|
||||
|
||||
databases[display_name] = entry
|
||||
|
||||
if errors and not databases:
|
||||
print(json.dumps({"error": "Parse errors", "details": errors, "debug": debug_info}), file=sys.stderr)
|
||||
|
||||
@@ -1054,52 +851,24 @@ function loadExistingData() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fall back to the previously-generated databases.json for capability flags
|
||||
* whose source assignment couldn't be statically resolved (e.g.
|
||||
* `allows_joins = is_feature_enabled("DRUID_JOINS")`). The Python extractor
|
||||
* flags these via the internal `_unresolved_cap_fields` marker; without this
|
||||
* fallback those fields would silently inherit the BaseEngineSpec default
|
||||
* and disagree with runtime behavior. The marker is stripped before output.
|
||||
*/
|
||||
function fallbackUnresolvedCaps(newDatabases, existingData) {
|
||||
for (const [name, db] of Object.entries(newDatabases)) {
|
||||
const unresolved = db._unresolved_cap_fields;
|
||||
if (!unresolved || unresolved.length === 0) {
|
||||
delete db._unresolved_cap_fields;
|
||||
continue;
|
||||
}
|
||||
const existingDb = existingData?.databases?.[name];
|
||||
if (existingDb) {
|
||||
for (const field of unresolved) {
|
||||
if (existingDb[field] !== undefined) {
|
||||
db[field] = existingDb[field];
|
||||
}
|
||||
}
|
||||
}
|
||||
delete db._unresolved_cap_fields;
|
||||
}
|
||||
return newDatabases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new documentation with existing diagnostics
|
||||
* Preserves score, max_score, and time_grains from existing data (these require
|
||||
* Flask context to generate and cannot be derived from static source analysis).
|
||||
* Capability flags (joins, supports_catalog, etc.) are NOT preserved here — they
|
||||
* are read fresh from the Python engine spec source by extractEngineSpecMetadata(),
|
||||
* with a separate fallback for expression-based assignments (see fallbackUnresolvedCaps).
|
||||
* Preserves score, time_grains, and feature flags from existing data
|
||||
*/
|
||||
function mergeWithExistingDiagnostics(newDatabases, existingData) {
|
||||
if (!existingData?.databases) return newDatabases;
|
||||
|
||||
// Only preserve fields that require Flask/runtime context to generate
|
||||
const diagnosticFields = ['score', 'max_score', 'time_grains'];
|
||||
const diagnosticFields = [
|
||||
'score', 'max_score', 'time_grains', 'joins', 'subqueries',
|
||||
'supports_dynamic_schema', 'supports_catalog', 'supports_dynamic_catalog',
|
||||
'ssh_tunneling', 'query_cancelation', 'supports_file_upload',
|
||||
'user_impersonation', 'query_cost_estimation', 'sql_validation'
|
||||
];
|
||||
|
||||
for (const [name, db] of Object.entries(newDatabases)) {
|
||||
const existingDb = existingData.databases[name];
|
||||
if (existingDb && existingDb.score > 0) {
|
||||
// Preserve score/time_grain diagnostics from existing data
|
||||
// Preserve diagnostics from existing data
|
||||
for (const field of diagnosticFields) {
|
||||
if (existingDb[field] !== undefined) {
|
||||
db[field] = existingDb[field];
|
||||
@@ -1110,7 +879,7 @@ function mergeWithExistingDiagnostics(newDatabases, existingData) {
|
||||
|
||||
const preserved = Object.values(newDatabases).filter(d => d.score > 0).length;
|
||||
if (preserved > 0) {
|
||||
console.log(`Preserved score/time_grains for ${preserved} databases from existing data`);
|
||||
console.log(`Preserved diagnostics for ${preserved} databases from existing data`);
|
||||
}
|
||||
|
||||
return newDatabases;
|
||||
@@ -1158,12 +927,6 @@ async function main() {
|
||||
databases = mergeWithExistingDiagnostics(databases, existingData);
|
||||
}
|
||||
|
||||
// For cap flags assigned via unevaluable expressions (e.g.
|
||||
// `is_feature_enabled(...)`), prefer the value from a previously-generated
|
||||
// JSON. Runs regardless of scores since it addresses static-analysis gaps,
|
||||
// not missing Flask diagnostics. Always strips the internal marker.
|
||||
databases = fallbackUnresolvedCaps(databases, existingData);
|
||||
|
||||
// Extract and merge custom_errors for troubleshooting documentation
|
||||
const customErrors = extractCustomErrors();
|
||||
mergeCustomErrors(databases, customErrors);
|
||||
|
||||
BIN
docs/static/img/screenshots/dashboard.jpg
vendored
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 134 KiB |
BIN
docs/static/img/screenshots/explore.jpg
vendored
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 104 KiB |
BIN
docs/static/img/screenshots/gallery.jpg
vendored
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
BIN
docs/static/img/screenshots/sql_lab.jpg
vendored
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 99 KiB |
BIN
docs/static/img/tutorial/create_pivot.png
vendored
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 51 KiB |
BIN
docs/static/img/tutorial/tutorial_chart_resize.png
vendored
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 85 KiB |
BIN
docs/static/img/tutorial/tutorial_edit_button.png
vendored
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 97 KiB |
BIN
docs/static/img/tutorial/tutorial_save_slice.png
vendored
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 141 KiB |
236
docs/yarn.lock
@@ -261,15 +261,6 @@
|
||||
js-tokens "^4.0.0"
|
||||
picocolors "^1.1.1"
|
||||
|
||||
"@babel/code-frame@^7.29.0":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c"
|
||||
integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==
|
||||
dependencies:
|
||||
"@babel/helper-validator-identifier" "^7.28.5"
|
||||
js-tokens "^4.0.0"
|
||||
picocolors "^1.1.1"
|
||||
|
||||
"@babel/compat-data@^7.27.7", "@babel/compat-data@^7.28.0":
|
||||
version "7.28.0"
|
||||
resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz"
|
||||
@@ -312,17 +303,6 @@
|
||||
"@jridgewell/trace-mapping" "^0.3.28"
|
||||
jsesc "^3.0.2"
|
||||
|
||||
"@babel/generator@^7.29.0":
|
||||
version "7.29.1"
|
||||
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.1.tgz#d09876290111abbb00ef962a7b83a5307fba0d50"
|
||||
integrity sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==
|
||||
dependencies:
|
||||
"@babel/parser" "^7.29.0"
|
||||
"@babel/types" "^7.29.0"
|
||||
"@jridgewell/gen-mapping" "^0.3.12"
|
||||
"@jridgewell/trace-mapping" "^0.3.28"
|
||||
jsesc "^3.0.2"
|
||||
|
||||
"@babel/helper-annotate-as-pure@^7.27.1", "@babel/helper-annotate-as-pure@^7.27.3":
|
||||
version "7.27.3"
|
||||
resolved "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz"
|
||||
@@ -424,11 +404,6 @@
|
||||
resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz"
|
||||
integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==
|
||||
|
||||
"@babel/helper-plugin-utils@^7.28.6":
|
||||
version "7.28.6"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz#6f13ea251b68c8532e985fd532f28741a8af9ac8"
|
||||
integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==
|
||||
|
||||
"@babel/helper-remap-async-to-generator@^7.27.1":
|
||||
version "7.27.1"
|
||||
resolved "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz"
|
||||
@@ -460,6 +435,11 @@
|
||||
resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz"
|
||||
integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
|
||||
|
||||
"@babel/helper-validator-identifier@^7.27.1":
|
||||
version "7.27.1"
|
||||
resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz"
|
||||
integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==
|
||||
|
||||
"@babel/helper-validator-identifier@^7.28.5":
|
||||
version "7.28.5"
|
||||
resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz"
|
||||
@@ -494,13 +474,6 @@
|
||||
dependencies:
|
||||
"@babel/types" "^7.28.6"
|
||||
|
||||
"@babel/parser@^7.29.0":
|
||||
version "7.29.3"
|
||||
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.3.tgz#116f70a77958307fceac27747573032f8a62f88e"
|
||||
integrity sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==
|
||||
dependencies:
|
||||
"@babel/types" "^7.29.0"
|
||||
|
||||
"@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.27.1":
|
||||
version "7.27.1"
|
||||
resolved "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz"
|
||||
@@ -785,14 +758,14 @@
|
||||
"@babel/helper-plugin-utils" "^7.27.1"
|
||||
|
||||
"@babel/plugin-transform-modules-systemjs@^7.27.1":
|
||||
version "7.29.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz#f621105da99919c15cf4bde6fcc7346ef95e7b20"
|
||||
integrity sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==
|
||||
version "7.27.1"
|
||||
resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz"
|
||||
integrity sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==
|
||||
dependencies:
|
||||
"@babel/helper-module-transforms" "^7.28.6"
|
||||
"@babel/helper-plugin-utils" "^7.28.6"
|
||||
"@babel/helper-validator-identifier" "^7.28.5"
|
||||
"@babel/traverse" "^7.29.0"
|
||||
"@babel/helper-module-transforms" "^7.27.1"
|
||||
"@babel/helper-plugin-utils" "^7.27.1"
|
||||
"@babel/helper-validator-identifier" "^7.27.1"
|
||||
"@babel/traverse" "^7.27.1"
|
||||
|
||||
"@babel/plugin-transform-modules-umd@^7.27.1":
|
||||
version "7.27.1"
|
||||
@@ -1190,19 +1163,6 @@
|
||||
"@babel/types" "^7.28.6"
|
||||
debug "^4.3.1"
|
||||
|
||||
"@babel/traverse@^7.29.0":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.0.tgz#f323d05001440253eead3c9c858adbe00b90310a"
|
||||
integrity sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.29.0"
|
||||
"@babel/generator" "^7.29.0"
|
||||
"@babel/helper-globals" "^7.28.0"
|
||||
"@babel/parser" "^7.29.0"
|
||||
"@babel/template" "^7.28.6"
|
||||
"@babel/types" "^7.29.0"
|
||||
debug "^4.3.1"
|
||||
|
||||
"@babel/types@^7.21.3", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.28.2", "@babel/types@^7.28.6", "@babel/types@^7.4.4":
|
||||
version "7.28.6"
|
||||
resolved "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz"
|
||||
@@ -1211,14 +1171,6 @@
|
||||
"@babel/helper-string-parser" "^7.27.1"
|
||||
"@babel/helper-validator-identifier" "^7.28.5"
|
||||
|
||||
"@babel/types@^7.29.0":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7"
|
||||
integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==
|
||||
dependencies:
|
||||
"@babel/helper-string-parser" "^7.27.1"
|
||||
"@babel/helper-validator-identifier" "^7.28.5"
|
||||
|
||||
"@braintree/sanitize-url@^7.0.4":
|
||||
version "7.1.1"
|
||||
resolved "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz"
|
||||
@@ -5088,100 +5040,100 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@8.59.3", "@typescript-eslint/eslint-plugin@^8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz#5d6da7e7b236b46452fa00d3904bb6f59615bfde"
|
||||
integrity sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==
|
||||
"@typescript-eslint/eslint-plugin@8.59.1", "@typescript-eslint/eslint-plugin@^8.52.0":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz#781bc6f9002982cfaf75a185240e24ad7276628a"
|
||||
integrity sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@typescript-eslint/scope-manager" "8.59.3"
|
||||
"@typescript-eslint/type-utils" "8.59.3"
|
||||
"@typescript-eslint/utils" "8.59.3"
|
||||
"@typescript-eslint/visitor-keys" "8.59.3"
|
||||
"@typescript-eslint/scope-manager" "8.59.1"
|
||||
"@typescript-eslint/type-utils" "8.59.1"
|
||||
"@typescript-eslint/utils" "8.59.1"
|
||||
"@typescript-eslint/visitor-keys" "8.59.1"
|
||||
ignore "^7.0.5"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/parser@8.59.3", "@typescript-eslint/parser@^8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.59.3.tgz#f46cbc70ae0a25119ef94eac9ecd46714788e1a1"
|
||||
integrity sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==
|
||||
"@typescript-eslint/parser@8.59.1", "@typescript-eslint/parser@^8.59.0":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.59.1.tgz#835d20a62350659a082a1ae2a60b822c40488905"
|
||||
integrity sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/typescript-estree" "8.59.3"
|
||||
"@typescript-eslint/visitor-keys" "8.59.3"
|
||||
"@typescript-eslint/scope-manager" "8.59.1"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
"@typescript-eslint/typescript-estree" "8.59.1"
|
||||
"@typescript-eslint/visitor-keys" "8.59.1"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.59.3.tgz#1be5ae152aad987a156c9a1a9b4256e75cfbbe0c"
|
||||
integrity sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==
|
||||
"@typescript-eslint/project-service@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.59.1.tgz#49efe87c37ef84262f23df8bf62fdc56698ca6fe"
|
||||
integrity sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.59.3"
|
||||
"@typescript-eslint/types" "^8.59.3"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.59.1"
|
||||
"@typescript-eslint/types" "^8.59.1"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz#91a60f66803fe9dae0696fbab2451f5723f119d2"
|
||||
integrity sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==
|
||||
"@typescript-eslint/scope-manager@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz#ed90d054fc3db2d0c81464db3a953a94fb85bb58"
|
||||
integrity sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/visitor-keys" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
"@typescript-eslint/visitor-keys" "8.59.1"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.59.3", "@typescript-eslint/tsconfig-utils@^8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz#88ca9036b42ccdd1e630cfdafd2e042c2ca6a835"
|
||||
integrity sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==
|
||||
"@typescript-eslint/tsconfig-utils@8.59.1", "@typescript-eslint/tsconfig-utils@^8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz#ba2a779a444f1d5cb92a606f9b209d239fd4cab1"
|
||||
integrity sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==
|
||||
|
||||
"@typescript-eslint/type-utils@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz#421fb2448bdfeb301d134a01cd02503f67fd8192"
|
||||
integrity sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==
|
||||
"@typescript-eslint/type-utils@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz#9c83d3f2ed9187a815e8120f72c08317e513e409"
|
||||
integrity sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/typescript-estree" "8.59.3"
|
||||
"@typescript-eslint/utils" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
"@typescript-eslint/typescript-estree" "8.59.1"
|
||||
"@typescript-eslint/utils" "8.59.1"
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/types@8.59.3", "@typescript-eslint/types@^8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.59.3.tgz#b7ca539c5e302fdde9a7cadb73caed107ef8f2cd"
|
||||
integrity sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==
|
||||
"@typescript-eslint/types@8.59.1", "@typescript-eslint/types@^8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.59.1.tgz#c1d014d3f03a97e0113a8899fc9d4e45a7fb0ca9"
|
||||
integrity sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz#e6bb1408e00b47e431427a40268db4e86cb121ab"
|
||||
integrity sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==
|
||||
"@typescript-eslint/typescript-estree@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz#4391fadf98a22c869c5b6522dbf4e491e53e351a"
|
||||
integrity sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.59.3"
|
||||
"@typescript-eslint/tsconfig-utils" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/visitor-keys" "8.59.3"
|
||||
"@typescript-eslint/project-service" "8.59.1"
|
||||
"@typescript-eslint/tsconfig-utils" "8.59.1"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
"@typescript-eslint/visitor-keys" "8.59.1"
|
||||
debug "^4.4.3"
|
||||
minimatch "^10.2.2"
|
||||
semver "^7.7.3"
|
||||
tinyglobby "^0.2.15"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/utils@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.3.tgz#f693f979deb4dc3994de03ff8b23976d625c36c5"
|
||||
integrity sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==
|
||||
"@typescript-eslint/utils@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.1.tgz#cf6204d69701bbbc5b150f98c18aeef0a42c10bd"
|
||||
integrity sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.9.1"
|
||||
"@typescript-eslint/scope-manager" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/typescript-estree" "8.59.3"
|
||||
"@typescript-eslint/scope-manager" "8.59.1"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
"@typescript-eslint/typescript-estree" "8.59.1"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz#820843b1b5ca4290009cf189382abcf6fe00dfa6"
|
||||
integrity sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==
|
||||
"@typescript-eslint/visitor-keys@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz#b5cba576287a3eeb0b400b62813189abcc3f976a"
|
||||
integrity sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@ungap/structured-clone@^1.0.0":
|
||||
@@ -5842,10 +5794,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.10.29, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.29"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz#47bdc13027af28d341f367a4f35a07ce872e27b4"
|
||||
integrity sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==
|
||||
baseline-browser-mapping@^2.10.27, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.27"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz#fee941c2a0b42cdf83c6427e4c830b1d0bdab2c3"
|
||||
integrity sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
@@ -6083,10 +6035,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.30001792:
|
||||
version "1.0.30001792"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz#ca8bb9be244835a335e2018272ce7223691873c5"
|
||||
integrity sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001791:
|
||||
version "1.0.30001791"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz#dfb93d85c40ad380c57123e72e10f3c575786b51"
|
||||
integrity sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
@@ -8166,9 +8118,9 @@ fast-safe-stringify@^2.0.7:
|
||||
integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==
|
||||
|
||||
fast-uri@^3.0.1:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.2.tgz#8af3d4fc9d3e71b11572cc2673b514a7d1a8c8ec"
|
||||
integrity sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==
|
||||
version "3.0.6"
|
||||
resolved "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz"
|
||||
integrity sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==
|
||||
|
||||
fastq@^1.6.0:
|
||||
version "1.19.1"
|
||||
@@ -14763,15 +14715,15 @@ types-ramda@^0.30.1:
|
||||
dependencies:
|
||||
ts-toolbelt "^9.6.0"
|
||||
|
||||
typescript-eslint@^8.59.3:
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.59.3.tgz#4a41d9007faa539a66292189e2795eeb0b9fca29"
|
||||
integrity sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==
|
||||
typescript-eslint@^8.59.1:
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.59.1.tgz#244a9fcbf27057ebbc2281d408239f1861b55b78"
|
||||
integrity sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin" "8.59.3"
|
||||
"@typescript-eslint/parser" "8.59.3"
|
||||
"@typescript-eslint/typescript-estree" "8.59.3"
|
||||
"@typescript-eslint/utils" "8.59.3"
|
||||
"@typescript-eslint/eslint-plugin" "8.59.1"
|
||||
"@typescript-eslint/parser" "8.59.1"
|
||||
"@typescript-eslint/typescript-estree" "8.59.1"
|
||||
"@typescript-eslint/utils" "8.59.1"
|
||||
|
||||
typescript@~6.0.3:
|
||||
version "6.0.3"
|
||||
|
||||
@@ -71,7 +71,7 @@ dependencies = [
|
||||
"marshmallow>=3.0, <4",
|
||||
"marshmallow-union>=0.1",
|
||||
"msgpack>=1.0.0, <1.2",
|
||||
"nh3>=0.2.11, <0.4",
|
||||
"nh3>=0.2.11, <0.3",
|
||||
"numpy>1.23.5, <2.3",
|
||||
"packaging",
|
||||
# --------------------------
|
||||
@@ -114,7 +114,7 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
athena = ["pyathena[pandas]>=2, <4"]
|
||||
athena = ["pyathena[pandas]>=2, <3"]
|
||||
aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"]
|
||||
bigquery = [
|
||||
"pandas-gbq>=0.19.1",
|
||||
@@ -131,28 +131,22 @@ d1 = [
|
||||
]
|
||||
databend = ["databend-sqlalchemy>=0.3.2, <1.0"]
|
||||
databricks = [
|
||||
"databricks-sql-connector==4.2.6",
|
||||
"databricks-sql-connector==4.1.2",
|
||||
"databricks-sqlalchemy==1.0.5",
|
||||
]
|
||||
db2 = ["ibm-db-sa>0.3.8, <=0.4.4"]
|
||||
denodo = ["denodo-sqlalchemy>=1.0.6,<2.1.0"]
|
||||
db2 = ["ibm-db-sa>0.3.8, <=0.4.0"]
|
||||
denodo = ["denodo-sqlalchemy~=1.0.6"]
|
||||
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
|
||||
drill = ["sqlalchemy-drill>=1.1.4, <2"]
|
||||
druid = ["pydruid>=0.6.5,<0.7"]
|
||||
duckdb = ["duckdb>=1.4.2,<2", "duckdb-engine>=0.17.0"]
|
||||
dynamodb = ["pydynamodb>=0.4.2"]
|
||||
solr = ["sqlalchemy-solr >= 0.2.0"]
|
||||
elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]
|
||||
elasticsearch = ["elasticsearch-dbapi>=0.2.12, <0.3.0"]
|
||||
exasol = ["sqlalchemy-exasol >= 2.4.0, <3.0"]
|
||||
excel = ["xlrd>=1.2.0, <1.3"]
|
||||
fastmcp = [
|
||||
"fastmcp>=3.2.4,<4.0",
|
||||
# tiktoken backs the response-size-guard token estimator. Without
|
||||
# it, the middleware falls back to a coarser character-based
|
||||
# heuristic that under-counts JSON-heavy MCP responses.
|
||||
"tiktoken>=0.7.0,<1.0",
|
||||
]
|
||||
firebird = ["sqlalchemy-firebird>=0.7.0, <2.2"]
|
||||
fastmcp = ["fastmcp>=3.2.4,<4.0"]
|
||||
firebird = ["sqlalchemy-firebird>=0.7.0, <0.8"]
|
||||
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
|
||||
gevent = ["gevent>=23.9.1"]
|
||||
gsheets = ["shillelagh[gsheetsapi]>=1.4.4, <2"]
|
||||
@@ -164,7 +158,7 @@ hive = [
|
||||
"thrift>=0.14.1, <1.0.0",
|
||||
"thrift_sasl>=0.4.3, < 1.0.0",
|
||||
]
|
||||
impala = ["impyla>0.16.2, <0.23"]
|
||||
impala = ["impyla>0.16.2, <0.17"]
|
||||
kusto = ["sqlalchemy-kusto>=3.0.0, <4"]
|
||||
kylin = ["kylinpy>=2.8.1, <2.9"]
|
||||
mssql = ["pymssql>=2.2.8, <3"]
|
||||
@@ -177,9 +171,9 @@ ocient = [
|
||||
"shapely",
|
||||
"geojson",
|
||||
]
|
||||
oracle = ["cx-Oracle>8.0.0, <8.4"]
|
||||
oracle = ["cx-Oracle>8.0.0, <8.1"]
|
||||
parseable = ["sqlalchemy-parseable>=0.1.3,<0.2.0"]
|
||||
pinot = ["pinotdb>=5.0.0, <10.0.0"]
|
||||
pinot = ["pinotdb>=5.0.0, <6.0.0"]
|
||||
playwright = ["playwright>=1.37.0, <2"]
|
||||
postgres = ["psycopg2-binary==2.9.12"]
|
||||
presto = ["pyhive[presto]>=0.6.5"]
|
||||
@@ -203,7 +197,7 @@ tdengine = [
|
||||
]
|
||||
teradata = ["teradatasql>=16.20.0.23"]
|
||||
thumbnails = [] # deprecated, will be removed in 7.0
|
||||
vertica = ["sqlalchemy-vertica-python>= 0.5.9, < 0.7"]
|
||||
vertica = ["sqlalchemy-vertica-python>=0.5.9, < 0.6"]
|
||||
netezza = ["nzalchemy>=11.0.2"]
|
||||
starrocks = ["starrocks>=1.0.0"]
|
||||
doris = ["pydoris>=1.0.0, <2.0.0"]
|
||||
@@ -224,7 +218,7 @@ development = [
|
||||
"progress>=1.5,<2",
|
||||
"psutil",
|
||||
"pyfakefs",
|
||||
"pyinstrument>=4.0.2,<6",
|
||||
"pyinstrument>=4.0.2,<5",
|
||||
"pylint",
|
||||
"pytest<8.0.0", # hairy issue with pytest >=8 where current_app proxies are not set in time
|
||||
"pytest-asyncio",
|
||||
@@ -383,7 +377,6 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"superset/mcp_service/app.py" = ["S608", "E501"] # LLM instruction text: SQL examples (S608) and long lines in multiline string (E501)
|
||||
"superset/mcp_service/*/tool/list_*.py" = ["E501"] # LLM docstring examples show full request shapes which exceed line length
|
||||
"scripts/*" = ["TID251"]
|
||||
"setup.py" = ["TID251"]
|
||||
"superset/config.py" = ["TID251"]
|
||||
@@ -394,7 +387,6 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
||||
"superset/utils/json.py" = ["TID251"]
|
||||
"docker/*" = ["I"] # Docker config files have non-standard imports that vary by environment
|
||||
"superset/db_engine_specs/lib.py" = ["E501"] # Database config file with long description strings
|
||||
"superset-frontend/plugins/plugin-chart-country-map/scripts/*" = ["TID251", "S310", "S603", "S607", "E501", "C901", "PT009"] # Standalone build pipeline outside superset/, intentionally invokes mapshaper via subprocess and downloads from Natural Earth
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
case-sensitive = false
|
||||
|
||||
@@ -183,9 +183,7 @@ idna==3.10
|
||||
# trio
|
||||
# url-normalize
|
||||
isodate==0.7.2
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
# via apache-superset (pyproject.toml)
|
||||
itsdangerous==2.2.0
|
||||
# via
|
||||
# flask
|
||||
@@ -298,7 +296,6 @@ pyarrow==20.0.0
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
pyasn1==0.6.3
|
||||
# via
|
||||
# pyasn1-modules
|
||||
|
||||
@@ -442,7 +442,6 @@ isodate==0.7.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
# apache-superset-core
|
||||
isort==6.0.1
|
||||
# via pylint
|
||||
itsdangerous==2.2.0
|
||||
@@ -716,7 +715,6 @@ pyarrow==20.0.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
# apache-superset-core
|
||||
# db-dtypes
|
||||
# pandas-gbq
|
||||
pyasn1==0.6.3
|
||||
@@ -868,8 +866,6 @@ referencing==0.36.2
|
||||
# jsonschema
|
||||
# jsonschema-path
|
||||
# jsonschema-specifications
|
||||
regex==2026.4.4
|
||||
# via tiktoken
|
||||
requests==2.33.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -882,7 +878,6 @@ requests==2.33.0
|
||||
# requests-cache
|
||||
# requests-oauthlib
|
||||
# shillelagh
|
||||
# tiktoken
|
||||
# trino
|
||||
requests-cache==1.2.1
|
||||
# via
|
||||
@@ -1008,8 +1003,6 @@ tabulate==0.9.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
tiktoken==0.12.0
|
||||
# via apache-superset
|
||||
tomli-w==1.2.0
|
||||
# via apache-superset-extensions-cli
|
||||
tomlkit==0.13.3
|
||||
|
||||
@@ -66,7 +66,7 @@ export type EmbedDashboardParams = {
|
||||
iframeTitle?: string;
|
||||
/** additional iframe sandbox attributes ex (allow-top-navigation, allow-popups-to-escape-sandbox) **/
|
||||
iframeSandboxExtras?: string[];
|
||||
/** Additional Permissions Policy features for the iframe's `allow` attribute (e.g., ['camera', 'microphone']). `fullscreen` and `clipboard-write` are granted by default. **/
|
||||
/** iframe allow attribute for Permissions Policy (e.g., ['clipboard-write', 'fullscreen']) **/
|
||||
iframeAllowExtras?: string[];
|
||||
/** force a specific refererPolicy to be used in the iframe request **/
|
||||
referrerPolicy?: ReferrerPolicy;
|
||||
@@ -233,14 +233,9 @@ export async function embedDashboard({
|
||||
iframe.src = `${supersetDomain}/embedded/${id}${urlParamsString}`;
|
||||
iframe.title = iframeTitle;
|
||||
iframe.style.background = 'transparent';
|
||||
// Permissions Policy features the embedded dashboard relies on. Modern
|
||||
// browsers gate these APIs on the iframe's `allow` attribute regardless
|
||||
// of sandbox flags, so we include them by default. Host apps can extend
|
||||
// the list via `iframeAllowExtras`.
|
||||
const allowFeatures = Array.from(
|
||||
new Set(['fullscreen', 'clipboard-write', ...iframeAllowExtras]),
|
||||
);
|
||||
iframe.setAttribute('allow', allowFeatures.join('; '));
|
||||
if (iframeAllowExtras.length > 0) {
|
||||
iframe.setAttribute('allow', iframeAllowExtras.join('; '));
|
||||
}
|
||||
//@ts-ignore
|
||||
mountPoint.replaceChildren(iframe);
|
||||
log('placed the iframe');
|
||||
|
||||
@@ -77,10 +77,6 @@ const restrictedImportsRules = {
|
||||
name: 'query-string',
|
||||
message: 'Please use the URLSearchParams API instead of query-string.',
|
||||
},
|
||||
'no-jest-mock-console': {
|
||||
name: 'jest-mock-console',
|
||||
message: 'Please use native Jest spies, i.e. jest.spyOn(console, "warn")',
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
||||
483
superset-frontend/package-lock.json
generated
@@ -62,7 +62,6 @@
|
||||
"@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-country-map": "file:./plugins/plugin-chart-country-map",
|
||||
"@superset-ui/plugin-chart-echarts": "file:./plugins/plugin-chart-echarts",
|
||||
"@superset-ui/plugin-chart-handlebars": "file:./plugins/plugin-chart-handlebars",
|
||||
"@superset-ui/plugin-chart-pivot-table": "file:./plugins/plugin-chart-pivot-table",
|
||||
@@ -97,7 +96,7 @@
|
||||
"fs-extra": "^11.3.4",
|
||||
"fuse.js": "^7.3.0",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.5.1",
|
||||
"geostyler": "^18.5.0",
|
||||
"geostyler-data": "^1.1.0",
|
||||
"geostyler-openlayers-parser": "^5.7.0",
|
||||
"geostyler-style": "11.0.2",
|
||||
@@ -116,7 +115,7 @@
|
||||
"memoize-one": "^5.2.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
"mustache": "^4.2.0",
|
||||
"nanoid": "^5.1.11",
|
||||
"nanoid": "^5.1.9",
|
||||
"ol": "^10.9.0",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"query-string": "9.3.1",
|
||||
@@ -223,8 +222,8 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.59.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
||||
"@typescript-eslint/parser": "^8.58.2",
|
||||
"babel-jest": "^30.0.2",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
@@ -250,7 +249,7 @@
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.9.3",
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
@@ -265,7 +264,7 @@
|
||||
"jest-html-reporter": "^4.4.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"jsdom": "^29.1.1",
|
||||
"jsdom": "^29.1.0",
|
||||
"lerna": "^9.0.4",
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
@@ -291,7 +290,7 @@
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"wait-on": "^9.0.6",
|
||||
"wait-on": "^9.0.5",
|
||||
"webpack": "^5.106.2",
|
||||
"webpack-bundle-analyzer": "^5.3.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
@@ -12182,10 +12181,6 @@
|
||||
"resolved": "plugins/plugin-chart-cartodiagram",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@superset-ui/plugin-chart-country-map": {
|
||||
"resolved": "plugins/plugin-chart-country-map",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@superset-ui/plugin-chart-echarts": {
|
||||
"resolved": "plugins/plugin-chart-echarts",
|
||||
"link": true
|
||||
@@ -14363,17 +14358,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
|
||||
"integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
|
||||
"integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.59.3",
|
||||
"@typescript-eslint/type-utils": "8.59.3",
|
||||
"@typescript-eslint/utils": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
||||
"@typescript-eslint/scope-manager": "8.59.1",
|
||||
"@typescript-eslint/type-utils": "8.59.1",
|
||||
"@typescript-eslint/utils": "8.59.1",
|
||||
"@typescript-eslint/visitor-keys": "8.59.1",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -14386,42 +14381,20 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.59.1",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
|
||||
"integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.59.3",
|
||||
"@typescript-eslint/types": "^8.59.3",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
|
||||
"integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz",
|
||||
"integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3"
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"@typescript-eslint/visitor-keys": "8.59.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -14431,27 +14404,10 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
|
||||
"integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
|
||||
"integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz",
|
||||
"integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14463,16 +14419,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
|
||||
"integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz",
|
||||
"integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.59.3",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
||||
"@typescript-eslint/project-service": "8.59.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.1",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"@typescript-eslint/visitor-keys": "8.59.1",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -14491,16 +14447,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz",
|
||||
"integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz",
|
||||
"integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/typescript-estree": "8.59.3"
|
||||
"@typescript-eslint/scope-manager": "8.59.1",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"@typescript-eslint/typescript-estree": "8.59.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -14515,13 +14471,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
|
||||
"integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz",
|
||||
"integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -14543,9 +14499,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -14595,16 +14551,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz",
|
||||
"integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz",
|
||||
"integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/typescript-estree": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
||||
"@typescript-eslint/scope-manager": "8.59.1",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"@typescript-eslint/typescript-estree": "8.59.1",
|
||||
"@typescript-eslint/visitor-keys": "8.59.1",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -14619,37 +14575,15 @@
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
|
||||
"integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.59.3",
|
||||
"@typescript-eslint/types": "^8.59.3",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
|
||||
"integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz",
|
||||
"integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3"
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"@typescript-eslint/visitor-keys": "8.59.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -14659,27 +14593,10 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
|
||||
"integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
|
||||
"integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz",
|
||||
"integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14691,16 +14608,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
|
||||
"integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz",
|
||||
"integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.59.3",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
||||
"@typescript-eslint/project-service": "8.59.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.1",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"@typescript-eslint/visitor-keys": "8.59.1",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -14719,13 +14636,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
|
||||
"integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz",
|
||||
"integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -14747,9 +14664,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -14860,15 +14777,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz",
|
||||
"integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz",
|
||||
"integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/typescript-estree": "8.59.3",
|
||||
"@typescript-eslint/utils": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"@typescript-eslint/typescript-estree": "8.59.1",
|
||||
"@typescript-eslint/utils": "8.59.1",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -14884,37 +14801,15 @@
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
|
||||
"integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.59.3",
|
||||
"@typescript-eslint/types": "^8.59.3",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
|
||||
"integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz",
|
||||
"integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3"
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"@typescript-eslint/visitor-keys": "8.59.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -14924,27 +14819,10 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
|
||||
"integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
|
||||
"integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz",
|
||||
"integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14956,16 +14834,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
|
||||
"integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz",
|
||||
"integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.59.3",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
||||
"@typescript-eslint/project-service": "8.59.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.1",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"@typescript-eslint/visitor-keys": "8.59.1",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -14984,16 +14862,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz",
|
||||
"integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz",
|
||||
"integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/typescript-estree": "8.59.3"
|
||||
"@typescript-eslint/scope-manager": "8.59.1",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"@typescript-eslint/typescript-estree": "8.59.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -15008,13 +14886,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
|
||||
"integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz",
|
||||
"integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -15036,9 +14914,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -17004,13 +16882,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
|
||||
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
@@ -22994,9 +22872,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-react-you-might-not-need-an-effect": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-0.10.0.tgz",
|
||||
"integrity": "sha512-a4pugbQc2zLiE2NZGuXdTjtMNvlP2984QFPDv71eskUYDzigLFYfBL4QjK+RnRtcboHoXRKOcQqEZKxiK6KegA==",
|
||||
"version": "0.9.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-0.9.3.tgz",
|
||||
"integrity": "sha512-44cce7LndBnpDRWBTQ8p7ircIdl2rJBP5+V9Ik64E935UB47uA9ZMU1Uv160lAMhtvoPYqXBjQ+tojr5JF3mFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -24882,9 +24760,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/geostyler": {
|
||||
"version": "18.5.1",
|
||||
"resolved": "https://registry.npmjs.org/geostyler/-/geostyler-18.5.1.tgz",
|
||||
"integrity": "sha512-5+vLuDo1oR4QQTnrfkccIQSe3qEn0ytV9dLiFFhnxhPdziv/Wp3vKNhJZ37MUF5yIj2ISWZ+q/VmSNH6ifvWpg==",
|
||||
"version": "18.5.0",
|
||||
"resolved": "https://registry.npmjs.org/geostyler/-/geostyler-18.5.0.tgz",
|
||||
"integrity": "sha512-azjLMEhrTQot+pU3phfSrUZI7CdetyAl7JNAnxrGaPA/E/5mmyoPQugZso3CfIuIBwOtFLmfB36SLE/FeGFakA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.5.1",
|
||||
@@ -30228,6 +30106,16 @@
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-mock-console": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-mock-console/-/jest-mock-console-2.0.0.tgz",
|
||||
"integrity": "sha512-7zrKtXVut+6doalosFxw/2O9spLepQJ9VukODtyLIub2fFkWKe1TyQrxr/GyQogTQcdkHfhvFJdx1OEzLqf/mw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"jest": ">= 22.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-pnp-resolver": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz",
|
||||
@@ -31689,9 +31577,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/joi": {
|
||||
"version": "18.2.1",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz",
|
||||
"integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==",
|
||||
"version": "18.1.2",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-18.1.2.tgz",
|
||||
"integrity": "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -31793,9 +31681,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "29.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||
"version": "29.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.0.tgz",
|
||||
"integrity": "sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -36815,9 +36703,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "5.1.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz",
|
||||
"integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==",
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.9.tgz",
|
||||
"integrity": "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -47929,9 +47817,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vm2": {
|
||||
"version": "3.11.3",
|
||||
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.11.3.tgz",
|
||||
"integrity": "sha512-DO1TTKuOc+veL11VNOvJwRab80mghFKE40Av3bl6pdXs11bdiDMuR73owy+dS2EsTZEvRUeBkkBuDVRjV/RgEw==",
|
||||
"version": "3.10.5",
|
||||
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.10.5.tgz",
|
||||
"integrity": "sha512-3P/2QDccVFBcujfCOeP8vVNuGfuBJHEuvGR8eMmI10p/iwLL2UwF5PDaNaoOS2pRGQEDmJRyeEcc8kmm2Z59RA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
@@ -47970,14 +47858,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wait-on": {
|
||||
"version": "9.0.6",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.6.tgz",
|
||||
"integrity": "sha512-KR+Te+NBg6DmPVil4anyIO72mpt/QDHjRo3nVFkwRgb26oweUp3DDW2szO3EeUY4cqafWy4rQuOOeEk4n+7Oeg==",
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.5.tgz",
|
||||
"integrity": "sha512-qgnbHDfDTRIp73ANEJNRW/7kn8CrDUcvZz18xotJQku/P4saTGkbIzvnMZebPmVvVNUiRq1qWAPyqCH+W4H8KA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.16.0",
|
||||
"joi": "^18.2.1",
|
||||
"axios": "^1.15.0",
|
||||
"joi": "^18.1.2",
|
||||
"lodash": "^4.18.1",
|
||||
"minimist": "^1.2.8",
|
||||
"rxjs": "^7.8.2"
|
||||
@@ -50211,6 +50099,7 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/seedrandom": "^3.0.8",
|
||||
"fetch-mock": "^12.6.0",
|
||||
"jest-mock-console": "^2.0.0",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"timezone-mock": "^1.4.2"
|
||||
},
|
||||
@@ -50686,7 +50575,7 @@
|
||||
"classnames": "^2.5.1",
|
||||
"d3-array": "^3.2.4",
|
||||
"lodash": "^4.18.1",
|
||||
"memoize-one": "^6.0.0",
|
||||
"memoize-one": "^5.2.1",
|
||||
"react-table": "^7.8.0",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"xss": "^1.0.15"
|
||||
@@ -50717,12 +50606,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"plugins/plugin-chart-ag-grid-table/node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"plugins/plugin-chart-cartodiagram": {
|
||||
"name": "@superset-ui/plugin-chart-cartodiagram",
|
||||
"version": "0.0.1",
|
||||
@@ -50750,72 +50633,6 @@
|
||||
"react-dom": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"plugins/plugin-chart-country-map": {
|
||||
"name": "@superset-ui/plugin-chart-country-map",
|
||||
"version": "0.20.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-geo": "^3.1.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-selection": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3-array": "^3.2.1",
|
||||
"@types/d3-color": "^3.1.3",
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/d3-selection": "^3.0.11",
|
||||
"@types/jest": "^30.0.0",
|
||||
"jest": "^30.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"plugins/plugin-chart-country-map/node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"plugins/plugin-chart-country-map/node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"plugins/plugin-chart-country-map/node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"plugins/plugin-chart-country-map/node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"plugins/plugin-chart-echarts": {
|
||||
"name": "@superset-ui/plugin-chart-echarts",
|
||||
"version": "0.20.3",
|
||||
@@ -51070,7 +50887,7 @@
|
||||
"@deck.gl/extensions": "~9.2.9",
|
||||
"@deck.gl/geo-layers": "~9.2.5",
|
||||
"@deck.gl/layers": "~9.2.5",
|
||||
"@deck.gl/mapbox": "~9.3.2",
|
||||
"@deck.gl/mapbox": "~9.3.1",
|
||||
"@deck.gl/mesh-layers": "~9.2.5",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
@@ -51118,16 +50935,16 @@
|
||||
}
|
||||
},
|
||||
"plugins/preset-chart-deckgl/node_modules/@deck.gl/mapbox": {
|
||||
"version": "9.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.3.2.tgz",
|
||||
"integrity": "sha512-+T9pJwsOXwjUxyGN6oiBMfIs28VtDIG1V1Rqz4qqn4TjjNEFFw+xO0olJIg8FO5IAqw2OtePdsrMj0tX8tHdGQ==",
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.3.1.tgz",
|
||||
"integrity": "sha512-4SgpWMeZiqiZEiz9yPdr89cVRL8HFcvXLxXUA0ExhMreUdNuK/j2OIQHPhw6vp1xCFbJEEqRelQ0pJYkhGDkYw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deck.gl/core": "~9.3.0",
|
||||
"@luma.gl/core": "~9.3.3",
|
||||
"@luma.gl/core": "~9.3.2",
|
||||
"@math.gl/web-mercator": "^4.1.0"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -144,7 +144,6 @@
|
||||
"@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-country-map": "file:./plugins/plugin-chart-country-map",
|
||||
"@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",
|
||||
@@ -178,7 +177,7 @@
|
||||
"fs-extra": "^11.3.4",
|
||||
"fuse.js": "^7.3.0",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.5.1",
|
||||
"geostyler": "^18.5.0",
|
||||
"geostyler-data": "^1.1.0",
|
||||
"geostyler-openlayers-parser": "^5.7.0",
|
||||
"geostyler-style": "11.0.2",
|
||||
@@ -197,7 +196,7 @@
|
||||
"memoize-one": "^5.2.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
"mustache": "^4.2.0",
|
||||
"nanoid": "^5.1.11",
|
||||
"nanoid": "^5.1.9",
|
||||
"ol": "^10.9.0",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"query-string": "9.3.1",
|
||||
@@ -304,8 +303,8 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.59.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
||||
"@typescript-eslint/parser": "^8.58.2",
|
||||
"babel-jest": "^30.0.2",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
@@ -331,7 +330,7 @@
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.9.3",
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
@@ -346,7 +345,7 @@
|
||||
"jest-html-reporter": "^4.4.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"jsdom": "^29.1.1",
|
||||
"jsdom": "^29.1.0",
|
||||
"lerna": "^9.0.4",
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
@@ -372,7 +371,7 @@
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"wait-on": "^9.0.6",
|
||||
"wait-on": "^9.0.5",
|
||||
"webpack": "^5.106.2",
|
||||
"webpack-bundle-analyzer": "^5.3.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
|
||||
@@ -38,17 +38,9 @@ import {
|
||||
import { normalizeThemeConfig, serializeThemeConfig } from './utils';
|
||||
|
||||
export class Theme {
|
||||
// Forward-compat: TS 6.0 enforces strictPropertyInitialization here;
|
||||
// both fields are assigned via setConfig() during construction, so we
|
||||
// use a definite-assignment assertion rather than hoisting the logic
|
||||
// out of setConfig().
|
||||
//
|
||||
// Assigned via setConfig() in the constructor; TypeScript 6.0's
|
||||
// strictPropertyInitialization can't trace that call chain, so we use
|
||||
// a definite-assignment assertion.
|
||||
theme!: SupersetTheme;
|
||||
theme: SupersetTheme;
|
||||
|
||||
private antdConfig!: AntdThemeConfig;
|
||||
private antdConfig: AntdThemeConfig;
|
||||
|
||||
private constructor({ config }: { config?: AnyThemeConfig }) {
|
||||
this.SupersetThemeProvider = this.SupersetThemeProvider.bind(this);
|
||||
|
||||
@@ -20,10 +20,3 @@
|
||||
* Stub for the untyped jed module.
|
||||
*/
|
||||
declare module 'jed';
|
||||
|
||||
/**
|
||||
* CSS side-effect imports from @fontsource packages. These are bundler-only
|
||||
* artifacts and carry no type information at runtime; declaring them here
|
||||
* silences TS2882 under TypeScript 6.0's stricter module-resolution rules.
|
||||
*/
|
||||
declare module '@fontsource/*';
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
|
||||
import { isMatrixifyVisible } from './matrixifyControls';
|
||||
import type { ControlStateMapping } from '../types';
|
||||
|
||||
/**
|
||||
* Helper to build a controls object matching the shape used by
|
||||
@@ -26,7 +25,7 @@ import type { ControlStateMapping } from '../types';
|
||||
*/
|
||||
function makeControls(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): ControlStateMapping {
|
||||
): Record<string, { value: unknown }> {
|
||||
const defaults: Record<string, unknown> = {
|
||||
matrixify_enable: false,
|
||||
matrixify_mode_rows: 'disabled',
|
||||
@@ -37,7 +36,7 @@ function makeControls(
|
||||
const merged = { ...defaults, ...overrides };
|
||||
return Object.fromEntries(
|
||||
Object.entries(merged).map(([k, v]) => [k, { value: v }]),
|
||||
) as ControlStateMapping;
|
||||
);
|
||||
}
|
||||
|
||||
// ── matrixify_enable guard ──────────────────────────────────────────
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { validateNonEmpty } from '@superset-ui/core';
|
||||
import { ControlStateMapping, SharedControlConfig } from '../types';
|
||||
import { SharedControlConfig } from '../types';
|
||||
import { dndAdhocMetricControl } from './dndControls';
|
||||
import { defineSavedMetrics } from '../utils';
|
||||
|
||||
@@ -29,12 +29,9 @@ import { defineSavedMetrics } from '../utils';
|
||||
* Controls for transforming charts into matrix/grid layouts
|
||||
*/
|
||||
|
||||
// Utility function to check if matrixify controls should be visible.
|
||||
// Controls both visibility callbacks and validator injection via mapStateToProps.
|
||||
// The matrixify_enable guard prevents hidden validators from firing on
|
||||
// pre-revamp charts with stale matrixify_mode defaults (fix for #38519).
|
||||
// Utility function to check if matrixify controls should be visible
|
||||
const isMatrixifyVisible = (
|
||||
controls: ControlStateMapping | undefined,
|
||||
controls: any,
|
||||
axis: 'rows' | 'columns',
|
||||
mode?: 'metrics' | 'dimensions',
|
||||
selectionMode?: 'members' | 'topn' | 'all',
|
||||
|
||||
@@ -1,238 +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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tests for the matrixify_enable guard in isMatrixifyVisible() and
|
||||
* validator injection via mapStateToProps on real matrixify control definitions.
|
||||
*
|
||||
* These are TDD tests for the fix to apache/superset#38519 regression:
|
||||
* isMatrixifyVisible() must check matrixify_enable before evaluating mode,
|
||||
* otherwise pre-revamp charts with stale matrixify_mode defaults trigger
|
||||
* hidden validators that block save.
|
||||
*/
|
||||
|
||||
import {
|
||||
matrixifyControls,
|
||||
isMatrixifyVisible,
|
||||
} from '../../src/shared-controls/matrixifyControls';
|
||||
import type { ControlPanelState, ControlStateMapping } from '../../src/types';
|
||||
|
||||
// Helper: build a minimal controls object for ControlPanelState
|
||||
const buildControls = (
|
||||
overrides: Record<string, any> = {},
|
||||
): ControlStateMapping => {
|
||||
const controls: Record<string, { value: any }> = {};
|
||||
Object.entries(overrides).forEach(([key, value]) => {
|
||||
controls[key] = { value };
|
||||
});
|
||||
return controls as ControlStateMapping;
|
||||
};
|
||||
|
||||
// Helper: build a minimal ControlPanelState for mapStateToProps.
|
||||
// Only provides fields that isMatrixifyVisible and mapStateToProps actually read.
|
||||
const buildState = (
|
||||
controlValues: Record<string, any> = {},
|
||||
formData: Record<string, any> = {},
|
||||
) =>
|
||||
({
|
||||
controls: buildControls(controlValues),
|
||||
datasource: { columns: [], type: 'table' },
|
||||
form_data: formData,
|
||||
common: {},
|
||||
metadata: {},
|
||||
slice: { slice_id: 0 },
|
||||
}) as unknown as ControlPanelState;
|
||||
|
||||
// ============================================================
|
||||
// Validator injection tests via real mapStateToProps (rows)
|
||||
// ============================================================
|
||||
|
||||
// --- matrixify_dimension_rows ---
|
||||
|
||||
test('matrixify_dimension_rows: validators empty when matrixify_enable is falsy', () => {
|
||||
const control = matrixifyControls.matrixify_dimension_rows;
|
||||
const state = buildState(
|
||||
{
|
||||
matrixify_enable: undefined,
|
||||
matrixify_mode_rows: 'dimensions',
|
||||
matrixify_dimension_selection_mode_rows: 'members',
|
||||
},
|
||||
{ matrixify_mode_rows: 'dimensions' },
|
||||
);
|
||||
|
||||
const result = control.mapStateToProps!(state, {} as any);
|
||||
expect(result.validators).toEqual([]);
|
||||
});
|
||||
|
||||
test('matrixify_dimension_rows: validators present when matrixify_enable is true', () => {
|
||||
const control = matrixifyControls.matrixify_dimension_rows;
|
||||
const state = buildState(
|
||||
{
|
||||
matrixify_enable: true,
|
||||
matrixify_mode_rows: 'dimensions',
|
||||
matrixify_dimension_selection_mode_rows: 'members',
|
||||
},
|
||||
{ matrixify_mode_rows: 'dimensions' },
|
||||
);
|
||||
|
||||
const result = control.mapStateToProps!(state, {} as any);
|
||||
expect(result.validators.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// --- matrixify_topn_value_rows ---
|
||||
|
||||
test('matrixify_topn_value_rows: validators empty when matrixify_enable is falsy', () => {
|
||||
const control = matrixifyControls.matrixify_topn_value_rows;
|
||||
const state = buildState(
|
||||
{
|
||||
matrixify_enable: undefined,
|
||||
matrixify_mode_rows: 'dimensions',
|
||||
matrixify_dimension_selection_mode_rows: 'topn',
|
||||
},
|
||||
{ matrixify_mode_rows: 'dimensions' },
|
||||
);
|
||||
|
||||
const result = control.mapStateToProps!(state, {} as any);
|
||||
expect(result.validators).toEqual([]);
|
||||
});
|
||||
|
||||
test('matrixify_topn_value_rows: validators present when matrixify_enable is true', () => {
|
||||
const control = matrixifyControls.matrixify_topn_value_rows;
|
||||
const state = buildState(
|
||||
{
|
||||
matrixify_enable: true,
|
||||
matrixify_mode_rows: 'dimensions',
|
||||
matrixify_dimension_selection_mode_rows: 'topn',
|
||||
},
|
||||
{ matrixify_mode_rows: 'dimensions' },
|
||||
);
|
||||
|
||||
const result = control.mapStateToProps!(state, {} as any);
|
||||
expect(result.validators.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// --- matrixify_topn_metric_rows ---
|
||||
|
||||
test('matrixify_topn_metric_rows: validators empty when matrixify_enable is falsy', () => {
|
||||
const control = matrixifyControls.matrixify_topn_metric_rows;
|
||||
const state = buildState(
|
||||
{
|
||||
matrixify_enable: undefined,
|
||||
matrixify_mode_rows: 'dimensions',
|
||||
matrixify_dimension_selection_mode_rows: 'topn',
|
||||
},
|
||||
{ matrixify_mode_rows: 'dimensions' },
|
||||
);
|
||||
|
||||
const result = control.mapStateToProps!(state, {} as any);
|
||||
expect(result.validators).toEqual([]);
|
||||
});
|
||||
|
||||
test('matrixify_topn_metric_rows: validators present when matrixify_enable is true', () => {
|
||||
const control = matrixifyControls.matrixify_topn_metric_rows;
|
||||
const state = buildState(
|
||||
{
|
||||
matrixify_enable: true,
|
||||
matrixify_mode_rows: 'dimensions',
|
||||
matrixify_dimension_selection_mode_rows: 'topn',
|
||||
},
|
||||
{ matrixify_mode_rows: 'dimensions' },
|
||||
);
|
||||
|
||||
const result = control.mapStateToProps!(state, {} as any);
|
||||
expect(result.validators.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Validator injection tests via real mapStateToProps (columns)
|
||||
// ============================================================
|
||||
|
||||
test('matrixify_dimension_columns: validators empty when matrixify_enable is falsy', () => {
|
||||
const control = matrixifyControls.matrixify_dimension_columns;
|
||||
const state = buildState(
|
||||
{
|
||||
matrixify_enable: undefined,
|
||||
matrixify_mode_columns: 'dimensions',
|
||||
matrixify_dimension_selection_mode_columns: 'members',
|
||||
},
|
||||
{ matrixify_mode_columns: 'dimensions' },
|
||||
);
|
||||
|
||||
const result = control.mapStateToProps!(state, {} as any);
|
||||
expect(result.validators).toEqual([]);
|
||||
});
|
||||
|
||||
test('matrixify_dimension_columns: validators present when matrixify_enable is true', () => {
|
||||
const control = matrixifyControls.matrixify_dimension_columns;
|
||||
const state = buildState(
|
||||
{
|
||||
matrixify_enable: true,
|
||||
matrixify_mode_columns: 'dimensions',
|
||||
matrixify_dimension_selection_mode_columns: 'members',
|
||||
},
|
||||
{ matrixify_mode_columns: 'dimensions' },
|
||||
);
|
||||
|
||||
const result = control.mapStateToProps!(state, {} as any);
|
||||
expect(result.validators.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Direct isMatrixifyVisible guard tests
|
||||
// ============================================================
|
||||
|
||||
test.each([
|
||||
['undefined', undefined],
|
||||
['null', null],
|
||||
['false', false],
|
||||
['0', 0],
|
||||
])(
|
||||
'isMatrixifyVisible returns false when matrixify_enable is %s',
|
||||
(_, value) => {
|
||||
const controls = buildControls({
|
||||
matrixify_enable: value,
|
||||
matrixify_mode_rows: 'dimensions',
|
||||
});
|
||||
expect(isMatrixifyVisible(controls, 'rows')).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
test('isMatrixifyVisible returns true when matrixify_enable is true and mode matches', () => {
|
||||
const controls = buildControls({
|
||||
matrixify_enable: true,
|
||||
matrixify_mode_rows: 'dimensions',
|
||||
});
|
||||
expect(isMatrixifyVisible(controls, 'rows', 'dimensions')).toBe(true);
|
||||
});
|
||||
|
||||
test('isMatrixifyVisible returns false when matrixify_enable is true but mode is disabled', () => {
|
||||
const controls = buildControls({
|
||||
matrixify_enable: true,
|
||||
matrixify_mode_rows: 'disabled',
|
||||
});
|
||||
expect(isMatrixifyVisible(controls, 'rows')).toBe(false);
|
||||
});
|
||||
|
||||
test('isMatrixifyVisible returns true when matrixify_enable is true and any non-disabled mode (no mode filter)', () => {
|
||||
const controls = buildControls({
|
||||
matrixify_enable: true,
|
||||
matrixify_mode_columns: 'metrics',
|
||||
});
|
||||
expect(isMatrixifyVisible(controls, 'columns')).toBe(true);
|
||||
});
|
||||
@@ -83,6 +83,7 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/seedrandom": "^3.0.8",
|
||||
"fetch-mock": "^12.6.0",
|
||||
"jest-mock-console": "^2.0.0",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"timezone-mock": "^1.4.2"
|
||||
},
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
/* eslint react/sort-comp: 'off' */
|
||||
import { PureComponent, ReactNode } from 'react';
|
||||
import {
|
||||
SupersetClientInterface,
|
||||
RequestConfig,
|
||||
@@ -66,112 +67,103 @@ export type ChartDataProviderState = {
|
||||
error?: ProvidedProps['error'];
|
||||
};
|
||||
|
||||
function ChartDataProvider({
|
||||
children,
|
||||
client,
|
||||
formData,
|
||||
sliceId,
|
||||
loadDatasource,
|
||||
onError,
|
||||
onLoaded,
|
||||
formDataRequestOptions,
|
||||
datasourceRequestOptions,
|
||||
queryRequestOptions,
|
||||
}: ChartDataProviderProps): JSX.Element | null {
|
||||
const [state, setState] = useState<ChartDataProviderState>({
|
||||
status: 'uninitialized',
|
||||
});
|
||||
class ChartDataProvider extends PureComponent<
|
||||
ChartDataProviderProps,
|
||||
ChartDataProviderState
|
||||
> {
|
||||
readonly chartClient: ChartClient;
|
||||
|
||||
const chartClient = useMemo(() => new ChartClient({ client }), [client]);
|
||||
constructor(props: ChartDataProviderProps) {
|
||||
super(props);
|
||||
this.state = { status: 'uninitialized' };
|
||||
this.chartClient = new ChartClient({ client: props.client });
|
||||
}
|
||||
|
||||
const extractSliceIdAndFormData = useCallback(
|
||||
(): SliceIdAndOrFormData =>
|
||||
formData ? { formData } : { sliceId: sliceId as number },
|
||||
[formData, sliceId],
|
||||
);
|
||||
componentDidMount() {
|
||||
this.handleFetchData();
|
||||
}
|
||||
|
||||
const handleReceiveData = useCallback(
|
||||
(payload?: Payload) => {
|
||||
if (onLoaded) onLoaded(payload);
|
||||
setState({ payload, status: 'loaded' });
|
||||
},
|
||||
[onLoaded],
|
||||
);
|
||||
|
||||
const handleError = useCallback(
|
||||
(error: ProvidedProps['error']) => {
|
||||
if (onError) onError(error);
|
||||
setState({ error, status: 'error' });
|
||||
},
|
||||
[onError],
|
||||
);
|
||||
|
||||
const handleFetchData = useCallback(() => {
|
||||
setState({ status: 'loading' });
|
||||
try {
|
||||
chartClient
|
||||
.loadFormData(extractSliceIdAndFormData(), formDataRequestOptions)
|
||||
.then(loadedFormData =>
|
||||
Promise.all([
|
||||
loadDatasource
|
||||
? chartClient.loadDatasource(
|
||||
loadedFormData.datasource,
|
||||
datasourceRequestOptions,
|
||||
)
|
||||
: Promise.resolve(undefined),
|
||||
chartClient.loadQueryData(loadedFormData, queryRequestOptions),
|
||||
]).then(
|
||||
([datasource, queriesData]) =>
|
||||
({
|
||||
datasource,
|
||||
formData: loadedFormData,
|
||||
queriesData,
|
||||
}) as Payload,
|
||||
),
|
||||
)
|
||||
.then(handleReceiveData)
|
||||
.catch(handleError);
|
||||
} catch (error) {
|
||||
handleError(error as Error);
|
||||
componentDidUpdate(prevProps: ChartDataProviderProps) {
|
||||
const { formData, sliceId } = this.props;
|
||||
if (formData !== prevProps.formData || sliceId !== prevProps.sliceId) {
|
||||
this.handleFetchData();
|
||||
}
|
||||
}, [
|
||||
chartClient,
|
||||
extractSliceIdAndFormData,
|
||||
formDataRequestOptions,
|
||||
loadDatasource,
|
||||
datasourceRequestOptions,
|
||||
queryRequestOptions,
|
||||
handleReceiveData,
|
||||
handleError,
|
||||
]);
|
||||
}
|
||||
|
||||
// Fetch on mount and only refetch when formData or sliceId changes.
|
||||
// This preserves the original class component's componentDidUpdate
|
||||
// semantics (which compared only formData and sliceId). Other
|
||||
// fetch-related inputs referenced by handleFetchData (callbacks and
|
||||
// request option props) are intentionally excluded from the dependency
|
||||
// array, so the exhaustive-deps rule is suppressed here.
|
||||
useEffect(() => {
|
||||
handleFetchData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formData, sliceId]);
|
||||
private extractSliceIdAndFormData() {
|
||||
const { formData, sliceId } = this.props;
|
||||
return formData ? { formData } : { sliceId: sliceId as number };
|
||||
}
|
||||
|
||||
const { status, payload, error } = state;
|
||||
private handleFetchData = () => {
|
||||
const {
|
||||
loadDatasource,
|
||||
formDataRequestOptions,
|
||||
datasourceRequestOptions,
|
||||
queryRequestOptions,
|
||||
} = this.props;
|
||||
|
||||
// Wrap the children result in a Fragment so the component's return type
|
||||
// stays `JSX.Element | null` (which TypeScript requires for JSX components)
|
||||
// while still letting consumers return any ReactNode (strings, fragments,
|
||||
// arrays, null, etc.) from the render prop.
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
return <>{children({ loading: true })}</>;
|
||||
case 'loaded':
|
||||
return <>{children({ payload })}</>;
|
||||
case 'error':
|
||||
return <>{children({ error })}</>;
|
||||
case 'uninitialized':
|
||||
default:
|
||||
return null;
|
||||
this.setState({ status: 'loading' }, () => {
|
||||
try {
|
||||
this.chartClient
|
||||
.loadFormData(
|
||||
this.extractSliceIdAndFormData(),
|
||||
formDataRequestOptions,
|
||||
)
|
||||
.then(formData =>
|
||||
Promise.all([
|
||||
loadDatasource
|
||||
? this.chartClient.loadDatasource(
|
||||
formData.datasource,
|
||||
datasourceRequestOptions,
|
||||
)
|
||||
: Promise.resolve(undefined),
|
||||
this.chartClient.loadQueryData(formData, queryRequestOptions),
|
||||
]).then(
|
||||
([datasource, queriesData]) =>
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
({
|
||||
datasource,
|
||||
formData,
|
||||
queriesData,
|
||||
}) as Payload,
|
||||
),
|
||||
)
|
||||
.then(this.handleReceiveData)
|
||||
.catch(this.handleError);
|
||||
} catch (error) {
|
||||
this.handleError(error as Error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private handleReceiveData = (payload?: Payload) => {
|
||||
const { onLoaded } = this.props;
|
||||
if (onLoaded) onLoaded(payload);
|
||||
this.setState({ payload, status: 'loaded' });
|
||||
};
|
||||
|
||||
private handleError = (error: ProvidedProps['error']) => {
|
||||
const { onError } = this.props;
|
||||
if (onError) onError(error);
|
||||
this.setState({ error, status: 'error' });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
const { status, payload, error } = this.state;
|
||||
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
return children({ loading: true });
|
||||
case 'loaded':
|
||||
return children({ payload });
|
||||
case 'error':
|
||||
return children({ error });
|
||||
case 'uninitialized':
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,11 +21,8 @@ import {
|
||||
ReactNode,
|
||||
RefObject,
|
||||
ComponentType,
|
||||
PureComponent,
|
||||
Fragment,
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
@@ -35,19 +32,23 @@ import {
|
||||
} from 'react-error-boundary';
|
||||
import { ParentSize } from '@visx/responsive';
|
||||
import { createSelector } from 'reselect';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { withTheme } from '@emotion/react';
|
||||
import { parseLength, Dimension } from '../../dimension';
|
||||
import getChartMetadataRegistry from '../registries/ChartMetadataRegistrySingleton';
|
||||
import SuperChartCore, {
|
||||
Props as SuperChartCoreProps,
|
||||
SuperChartCoreRef,
|
||||
} from './SuperChartCore';
|
||||
import SuperChartCore, { Props as SuperChartCoreProps } from './SuperChartCore';
|
||||
import DefaultFallbackComponent from './FallbackComponent';
|
||||
import ChartProps, { ChartPropsConfig } from '../models/ChartProps';
|
||||
import NoResultsComponent from './NoResultsComponent';
|
||||
import { isMatrixifyEnabled } from '../types/matrixify';
|
||||
import MatrixifyGridRenderer from './Matrixify/MatrixifyGridRenderer';
|
||||
import { supersetTheme, SupersetTheme } from '@apache-superset/core/theme';
|
||||
|
||||
const defaultProps = {
|
||||
FallbackComponent: DefaultFallbackComponent,
|
||||
height: 400 as string | number,
|
||||
width: '100%' as string | number,
|
||||
enableNoResults: true,
|
||||
};
|
||||
|
||||
export type FallbackPropsWithDimension = FallbackProps & Partial<Dimension>;
|
||||
|
||||
export type WrapperProps = Dimension & {
|
||||
@@ -55,9 +56,7 @@ export type WrapperProps = Dimension & {
|
||||
};
|
||||
|
||||
export type Props = Omit<SuperChartCoreProps, 'chartProps'> &
|
||||
Omit<ChartPropsConfig, 'width' | 'height' | 'theme'> & {
|
||||
/** Theme object (optional, falls back to ThemeProvider context) */
|
||||
theme?: SupersetTheme;
|
||||
Omit<ChartPropsConfig, 'width' | 'height'> & {
|
||||
/**
|
||||
* Set this to true to disable error boundary built-in in SuperChart
|
||||
* and let the error propagate to upper level
|
||||
@@ -103,269 +102,215 @@ export type Props = Omit<SuperChartCoreProps, 'chartProps'> &
|
||||
inContextMenu?: boolean;
|
||||
};
|
||||
|
||||
function SuperChart({
|
||||
id,
|
||||
className,
|
||||
chartType,
|
||||
preTransformProps,
|
||||
overrideTransformProps,
|
||||
postTransformProps,
|
||||
onRenderSuccess,
|
||||
onRenderFailure,
|
||||
disableErrorBoundary,
|
||||
FallbackComponent = DefaultFallbackComponent,
|
||||
onErrorBoundary,
|
||||
Wrapper,
|
||||
queriesData,
|
||||
enableNoResults = true,
|
||||
noResults,
|
||||
theme: themeProp,
|
||||
debounceTime,
|
||||
height = 400,
|
||||
width = '100%',
|
||||
...rest
|
||||
}: Props): JSX.Element {
|
||||
type PropsWithDefault = Props & Readonly<typeof defaultProps>;
|
||||
|
||||
class SuperChart extends PureComponent<Props, {}> {
|
||||
/**
|
||||
* SuperChart's core ref
|
||||
* SuperChart's core
|
||||
*/
|
||||
const coreRef = useRef<SuperChartCoreRef | null>(null);
|
||||
core?: SuperChartCore | null;
|
||||
|
||||
// Use theme from prop if provided, otherwise from context.
|
||||
// When no ThemeProvider is present, useTheme() returns an empty object,
|
||||
// so we fall back to the default supersetTheme to avoid passing an invalid theme downstream.
|
||||
const themeFromContext = useTheme() as Partial<SupersetTheme>;
|
||||
const theme =
|
||||
themeProp ??
|
||||
(Object.keys(themeFromContext).length > 0
|
||||
? (themeFromContext as SupersetTheme)
|
||||
: supersetTheme);
|
||||
private createChartProps = ChartProps.createSelector();
|
||||
|
||||
const createChartProps = useMemo(() => ChartProps.createSelector(), []);
|
||||
|
||||
const parseDimension = useMemo(
|
||||
() =>
|
||||
createSelector(
|
||||
[
|
||||
({ width: w }: { width: string | number; height: string | number }) =>
|
||||
w,
|
||||
({
|
||||
height: h,
|
||||
}: {
|
||||
width: string | number;
|
||||
height: string | number;
|
||||
}) => h,
|
||||
],
|
||||
(w, h) => {
|
||||
// Parse them in case they are % or 'auto'
|
||||
const widthInfo = parseLength(w);
|
||||
const heightInfo = parseLength(h);
|
||||
const boxHeight = heightInfo.isDynamic
|
||||
? `${heightInfo.multiplier * 100}%`
|
||||
: heightInfo.value;
|
||||
const boxWidth = widthInfo.isDynamic
|
||||
? `${widthInfo.multiplier * 100}%`
|
||||
: widthInfo.value;
|
||||
const style = {
|
||||
height: boxHeight,
|
||||
width: boxWidth,
|
||||
};
|
||||
|
||||
// bounding box will ensure that when one dimension is not dynamic
|
||||
// e.g. height = 300
|
||||
// the auto size will be bound to that value instead of being 100% by default
|
||||
// e.g. height: 300 instead of height: '100%'
|
||||
const BoundingBox =
|
||||
widthInfo.isDynamic &&
|
||||
heightInfo.isDynamic &&
|
||||
widthInfo.multiplier === 1 &&
|
||||
heightInfo.multiplier === 1
|
||||
? Fragment
|
||||
: ({ children }: { children: ReactNode }) => (
|
||||
<div style={style}>{children}</div>
|
||||
);
|
||||
|
||||
return { BoundingBox, heightInfo, widthInfo };
|
||||
},
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const setRef = useCallback((core: SuperChartCoreRef | null) => {
|
||||
coreRef.current = core;
|
||||
}, []);
|
||||
|
||||
const getQueryCount = useCallback(
|
||||
() => getChartMetadataRegistry().get(chartType)?.queryObjectCount ?? 1,
|
||||
[chartType],
|
||||
);
|
||||
|
||||
const renderChart = useCallback(
|
||||
(chartWidth: number, chartHeight: number) => {
|
||||
const chartProps = createChartProps({
|
||||
...rest,
|
||||
queriesData,
|
||||
height: chartHeight,
|
||||
width: chartWidth,
|
||||
theme,
|
||||
});
|
||||
|
||||
// Check if Matrixify is enabled - use rawFormData (snake_case)
|
||||
const matrixifyEnabled = isMatrixifyEnabled(chartProps.rawFormData);
|
||||
|
||||
if (matrixifyEnabled) {
|
||||
// When matrixify is enabled, queriesData is expected to be empty
|
||||
// since each cell fetches its own data via StatefulChart
|
||||
const matrixifyChart = (
|
||||
<MatrixifyGridRenderer
|
||||
formData={chartProps.rawFormData}
|
||||
datasource={chartProps.datasource}
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
hooks={chartProps.hooks}
|
||||
/>
|
||||
);
|
||||
|
||||
// Apply wrapper if provided
|
||||
const wrappedChart = Wrapper ? (
|
||||
<Wrapper width={chartWidth} height={chartHeight}>
|
||||
{matrixifyChart}
|
||||
</Wrapper>
|
||||
) : (
|
||||
matrixifyChart
|
||||
);
|
||||
|
||||
// Include error boundary unless disabled
|
||||
return disableErrorBoundary === true ? (
|
||||
wrappedChart
|
||||
) : (
|
||||
<ErrorBoundary
|
||||
FallbackComponent={props => (
|
||||
<FallbackComponent
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
onError={onErrorBoundary}
|
||||
>
|
||||
{wrappedChart}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// Check for no results only for non-matrixified charts
|
||||
const noResultQueries =
|
||||
enableNoResults &&
|
||||
(!queriesData ||
|
||||
queriesData
|
||||
.slice(0, getQueryCount())
|
||||
.every(
|
||||
({ data }) => !data || (Array.isArray(data) && data.length === 0),
|
||||
));
|
||||
|
||||
let chart: JSX.Element;
|
||||
if (noResultQueries) {
|
||||
chart = noResults ? (
|
||||
<>{noResults}</>
|
||||
) : (
|
||||
<NoResultsComponent
|
||||
id={id}
|
||||
className={className}
|
||||
height={chartHeight}
|
||||
width={chartWidth}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const chartWithoutWrapper = (
|
||||
<SuperChartCore
|
||||
ref={setRef}
|
||||
id={id}
|
||||
className={className}
|
||||
chartType={chartType}
|
||||
chartProps={chartProps}
|
||||
preTransformProps={preTransformProps}
|
||||
overrideTransformProps={overrideTransformProps}
|
||||
postTransformProps={postTransformProps}
|
||||
onRenderSuccess={onRenderSuccess}
|
||||
onRenderFailure={onRenderFailure}
|
||||
/>
|
||||
);
|
||||
chart = Wrapper ? (
|
||||
<Wrapper width={chartWidth} height={chartHeight}>
|
||||
{chartWithoutWrapper}
|
||||
</Wrapper>
|
||||
) : (
|
||||
chartWithoutWrapper
|
||||
);
|
||||
}
|
||||
// Include the error boundary by default unless it is specifically disabled.
|
||||
return disableErrorBoundary === true ? (
|
||||
chart
|
||||
) : (
|
||||
<ErrorBoundary
|
||||
FallbackComponent={props => (
|
||||
<FallbackComponent
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
onError={onErrorBoundary}
|
||||
>
|
||||
{chart}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
},
|
||||
private parseDimension = createSelector(
|
||||
[
|
||||
createChartProps,
|
||||
rest,
|
||||
queriesData,
|
||||
theme,
|
||||
Wrapper,
|
||||
disableErrorBoundary,
|
||||
FallbackComponent,
|
||||
onErrorBoundary,
|
||||
enableNoResults,
|
||||
getQueryCount,
|
||||
noResults,
|
||||
({ width }: { width: string | number; height: string | number }) => width,
|
||||
({ height }) => height,
|
||||
],
|
||||
(width, height) => {
|
||||
// Parse them in case they are % or 'auto'
|
||||
const widthInfo = parseLength(width);
|
||||
const heightInfo = parseLength(height);
|
||||
const boxHeight = heightInfo.isDynamic
|
||||
? `${heightInfo.multiplier * 100}%`
|
||||
: heightInfo.value;
|
||||
const boxWidth = widthInfo.isDynamic
|
||||
? `${widthInfo.multiplier * 100}%`
|
||||
: widthInfo.value;
|
||||
const style = {
|
||||
height: boxHeight,
|
||||
width: boxWidth,
|
||||
};
|
||||
|
||||
// bounding box will ensure that when one dimension is not dynamic
|
||||
// e.g. height = 300
|
||||
// the auto size will be bound to that value instead of being 100% by default
|
||||
// e.g. height: 300 instead of height: '100%'
|
||||
const BoundingBox =
|
||||
widthInfo.isDynamic &&
|
||||
heightInfo.isDynamic &&
|
||||
widthInfo.multiplier === 1 &&
|
||||
heightInfo.multiplier === 1
|
||||
? Fragment
|
||||
: ({ children }: { children: ReactNode }) => (
|
||||
<div style={style}>{children}</div>
|
||||
);
|
||||
|
||||
return { BoundingBox, heightInfo, widthInfo };
|
||||
},
|
||||
);
|
||||
|
||||
static defaultProps = defaultProps;
|
||||
|
||||
private setRef = (core: SuperChartCore | null) => {
|
||||
this.core = core;
|
||||
};
|
||||
|
||||
private getQueryCount = () =>
|
||||
getChartMetadataRegistry().get(this.props.chartType)?.queryObjectCount ?? 1;
|
||||
|
||||
renderChart(width: number, height: number) {
|
||||
const {
|
||||
id,
|
||||
className,
|
||||
setRef,
|
||||
chartType,
|
||||
preTransformProps,
|
||||
overrideTransformProps,
|
||||
postTransformProps,
|
||||
onRenderSuccess,
|
||||
onRenderFailure,
|
||||
],
|
||||
);
|
||||
disableErrorBoundary,
|
||||
FallbackComponent,
|
||||
onErrorBoundary,
|
||||
Wrapper,
|
||||
queriesData,
|
||||
enableNoResults,
|
||||
noResults,
|
||||
theme,
|
||||
...rest
|
||||
} = this.props as PropsWithDefault;
|
||||
|
||||
const { heightInfo, widthInfo, BoundingBox } = parseDimension({
|
||||
width,
|
||||
height,
|
||||
});
|
||||
const chartProps = this.createChartProps({
|
||||
...rest,
|
||||
queriesData,
|
||||
height,
|
||||
width,
|
||||
theme,
|
||||
});
|
||||
|
||||
// If any of the dimension is dynamic, get parent's dimension
|
||||
if (widthInfo.isDynamic || heightInfo.isDynamic) {
|
||||
return (
|
||||
<BoundingBox>
|
||||
<ParentSize debounceTime={debounceTime}>
|
||||
{({ width: parentWidth, height: parentHeight }) =>
|
||||
renderChart(
|
||||
widthInfo.isDynamic ? Math.floor(parentWidth) : widthInfo.value,
|
||||
heightInfo.isDynamic
|
||||
? Math.floor(parentHeight)
|
||||
: heightInfo.value,
|
||||
)
|
||||
}
|
||||
</ParentSize>
|
||||
</BoundingBox>
|
||||
// Check if Matrixify is enabled - use rawFormData (snake_case)
|
||||
const matrixifyEnabled = isMatrixifyEnabled(chartProps.rawFormData);
|
||||
|
||||
if (matrixifyEnabled) {
|
||||
// When matrixify is enabled, queriesData is expected to be empty
|
||||
// since each cell fetches its own data via StatefulChart
|
||||
const matrixifyChart = (
|
||||
<MatrixifyGridRenderer
|
||||
formData={chartProps.rawFormData}
|
||||
datasource={chartProps.datasource}
|
||||
width={width}
|
||||
height={height}
|
||||
hooks={chartProps.hooks}
|
||||
/>
|
||||
);
|
||||
|
||||
// Apply wrapper if provided
|
||||
const wrappedChart = Wrapper ? (
|
||||
<Wrapper width={width} height={height}>
|
||||
{matrixifyChart}
|
||||
</Wrapper>
|
||||
) : (
|
||||
matrixifyChart
|
||||
);
|
||||
|
||||
// Include error boundary unless disabled
|
||||
return disableErrorBoundary === true ? (
|
||||
wrappedChart
|
||||
) : (
|
||||
<ErrorBoundary
|
||||
FallbackComponent={props => (
|
||||
<FallbackComponent width={width} height={height} {...props} />
|
||||
)}
|
||||
onError={onErrorBoundary}
|
||||
>
|
||||
{wrappedChart}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// Check for no results only for non-matrixified charts
|
||||
const noResultQueries =
|
||||
enableNoResults &&
|
||||
(!queriesData ||
|
||||
queriesData
|
||||
.slice(0, this.getQueryCount())
|
||||
.every(
|
||||
({ data }) => !data || (Array.isArray(data) && data.length === 0),
|
||||
));
|
||||
|
||||
let chart;
|
||||
if (noResultQueries) {
|
||||
chart = noResults || (
|
||||
<NoResultsComponent
|
||||
id={id}
|
||||
className={className}
|
||||
height={height}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const chartWithoutWrapper = (
|
||||
<SuperChartCore
|
||||
ref={this.setRef}
|
||||
id={id}
|
||||
className={className}
|
||||
chartType={chartType}
|
||||
chartProps={chartProps}
|
||||
preTransformProps={preTransformProps}
|
||||
overrideTransformProps={overrideTransformProps}
|
||||
postTransformProps={postTransformProps}
|
||||
onRenderSuccess={onRenderSuccess}
|
||||
onRenderFailure={onRenderFailure}
|
||||
/>
|
||||
);
|
||||
chart = Wrapper ? (
|
||||
<Wrapper width={width} height={height}>
|
||||
{chartWithoutWrapper}
|
||||
</Wrapper>
|
||||
) : (
|
||||
chartWithoutWrapper
|
||||
);
|
||||
}
|
||||
// Include the error boundary by default unless it is specifically disabled.
|
||||
return disableErrorBoundary === true ? (
|
||||
chart
|
||||
) : (
|
||||
<ErrorBoundary
|
||||
FallbackComponent={props => (
|
||||
<FallbackComponent width={width} height={height} {...props} />
|
||||
)}
|
||||
onError={onErrorBoundary}
|
||||
>
|
||||
{chart}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
return renderChart(widthInfo.value, heightInfo.value);
|
||||
render() {
|
||||
const { heightInfo, widthInfo, BoundingBox } = this.parseDimension(
|
||||
this.props as PropsWithDefault,
|
||||
);
|
||||
|
||||
// If any of the dimension is dynamic, get parent's dimension
|
||||
if (widthInfo.isDynamic || heightInfo.isDynamic) {
|
||||
const { debounceTime } = this.props;
|
||||
|
||||
return (
|
||||
<BoundingBox>
|
||||
<ParentSize debounceTime={debounceTime}>
|
||||
{({ width, height }) =>
|
||||
this.renderChart(
|
||||
widthInfo.isDynamic ? Math.floor(width) : widthInfo.value,
|
||||
heightInfo.isDynamic ? Math.floor(height) : heightInfo.value,
|
||||
)
|
||||
}
|
||||
</ParentSize>
|
||||
</BoundingBox>
|
||||
);
|
||||
}
|
||||
|
||||
return this.renderChart(widthInfo.value, heightInfo.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap in memo to preserve the shallow-prop-comparison behavior
|
||||
// of the original PureComponent implementation.
|
||||
export default memo(SuperChart);
|
||||
export default withTheme(SuperChart);
|
||||
|
||||
@@ -17,13 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
/* eslint-disable react/jsx-sort-default-props */
|
||||
import { PureComponent } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { createSelector } from 'reselect';
|
||||
import getChartComponentRegistry from '../registries/ChartComponentRegistrySingleton';
|
||||
@@ -44,6 +39,16 @@ function IDENTITY<T>(x: T) {
|
||||
|
||||
const EMPTY = () => null;
|
||||
|
||||
const defaultProps = {
|
||||
id: '',
|
||||
className: '',
|
||||
preTransformProps: IDENTITY,
|
||||
overrideTransformProps: undefined,
|
||||
postTransformProps: IDENTITY,
|
||||
onRenderSuccess() {},
|
||||
onRenderFailure() {},
|
||||
};
|
||||
|
||||
interface LoadingProps {
|
||||
error: { toString(): string };
|
||||
}
|
||||
@@ -73,231 +78,174 @@ export type Props = {
|
||||
onRenderFailure?: HandlerFunction;
|
||||
};
|
||||
|
||||
export interface SuperChartCoreRef {
|
||||
container: HTMLElement | null;
|
||||
}
|
||||
export default class SuperChartCore extends PureComponent<Props, {}> {
|
||||
/**
|
||||
* The HTML element that wraps all chart content
|
||||
*/
|
||||
container?: HTMLElement | null;
|
||||
|
||||
const SuperChartCore = forwardRef<SuperChartCoreRef, Props>(
|
||||
function SuperChartCore(
|
||||
{
|
||||
id = '',
|
||||
className = '',
|
||||
chartProps = BLANK_CHART_PROPS,
|
||||
chartType,
|
||||
preTransformProps = IDENTITY,
|
||||
overrideTransformProps,
|
||||
postTransformProps = IDENTITY,
|
||||
onRenderSuccess = () => {},
|
||||
onRenderFailure = () => {},
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const containerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Expose container via ref
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
get container() {
|
||||
return containerRef.current;
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* memoized function so it will not recompute and return previous value
|
||||
* unless one of
|
||||
* - preTransformProps
|
||||
* - chartProps
|
||||
* is changed.
|
||||
*/
|
||||
const preSelector = useMemo(
|
||||
() =>
|
||||
createSelector(
|
||||
[
|
||||
(input: {
|
||||
chartProps: ChartProps;
|
||||
preTransformProps?: PreTransformProps;
|
||||
}) => input.chartProps,
|
||||
input => input.preTransformProps,
|
||||
],
|
||||
(inputChartProps, pre = IDENTITY) => pre(inputChartProps),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* memoized function so it will not recompute and return previous value
|
||||
* unless one of the input arguments have changed.
|
||||
*/
|
||||
const transformSelector = useMemo(
|
||||
() =>
|
||||
createSelector(
|
||||
[
|
||||
(input: {
|
||||
chartProps: ChartProps;
|
||||
transformProps?: TransformProps;
|
||||
}) => input.chartProps,
|
||||
input => input.transformProps,
|
||||
],
|
||||
(preprocessedChartProps, transform = IDENTITY) =>
|
||||
transform(preprocessedChartProps),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* memoized function so it will not recompute and return previous value
|
||||
* unless one of the input arguments have changed.
|
||||
*/
|
||||
const postSelector = useMemo(
|
||||
() =>
|
||||
createSelector(
|
||||
[
|
||||
(input: {
|
||||
chartProps: ChartProps;
|
||||
postTransformProps?: PostTransformProps;
|
||||
}) => input.chartProps,
|
||||
input => input.postTransformProps,
|
||||
],
|
||||
(transformedChartProps, post = IDENTITY) =>
|
||||
post(transformedChartProps),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Using each memoized function to retrieve the computed chartProps
|
||||
*/
|
||||
const processChartProps = useCallback(
|
||||
({
|
||||
chartProps: inputChartProps,
|
||||
preTransformProps: pre,
|
||||
transformProps,
|
||||
postTransformProps: post,
|
||||
}: {
|
||||
/**
|
||||
* memoized function so it will not recompute and return previous value
|
||||
* unless one of
|
||||
* - preTransformProps
|
||||
* - chartProps
|
||||
* is changed.
|
||||
*/
|
||||
preSelector = createSelector(
|
||||
[
|
||||
(input: {
|
||||
chartProps: ChartProps;
|
||||
preTransformProps?: PreTransformProps;
|
||||
transformProps?: TransformProps;
|
||||
}) => input.chartProps,
|
||||
input => input.preTransformProps,
|
||||
],
|
||||
(chartProps, pre = IDENTITY) => pre(chartProps),
|
||||
);
|
||||
|
||||
/**
|
||||
* memoized function so it will not recompute and return previous value
|
||||
* unless one of the input arguments have changed.
|
||||
*/
|
||||
transformSelector = createSelector(
|
||||
[
|
||||
(input: { chartProps: ChartProps; transformProps?: TransformProps }) =>
|
||||
input.chartProps,
|
||||
input => input.transformProps,
|
||||
],
|
||||
(preprocessedChartProps, transform = IDENTITY) =>
|
||||
transform(preprocessedChartProps),
|
||||
);
|
||||
|
||||
/**
|
||||
* memoized function so it will not recompute and return previous value
|
||||
* unless one of the input arguments have changed.
|
||||
*/
|
||||
postSelector = createSelector(
|
||||
[
|
||||
(input: {
|
||||
chartProps: ChartProps;
|
||||
postTransformProps?: PostTransformProps;
|
||||
}) =>
|
||||
postSelector({
|
||||
chartProps: transformSelector({
|
||||
chartProps: preSelector({
|
||||
chartProps: inputChartProps,
|
||||
preTransformProps: pre,
|
||||
}),
|
||||
transformProps,
|
||||
}),
|
||||
postTransformProps: post,
|
||||
}),
|
||||
[preSelector, transformSelector, postSelector],
|
||||
);
|
||||
}) => input.chartProps,
|
||||
input => input.postTransformProps,
|
||||
],
|
||||
(transformedChartProps, post = IDENTITY) => post(transformedChartProps),
|
||||
);
|
||||
|
||||
const renderLoading = useCallback(
|
||||
(loadingProps: LoadingProps, loadingChartType: string) => {
|
||||
const { error } = loadingProps;
|
||||
/**
|
||||
* Using each memoized function to retrieve the computed chartProps
|
||||
*/
|
||||
processChartProps = ({
|
||||
chartProps,
|
||||
preTransformProps,
|
||||
transformProps,
|
||||
postTransformProps,
|
||||
}: {
|
||||
chartProps: ChartProps;
|
||||
preTransformProps?: PreTransformProps;
|
||||
transformProps?: TransformProps;
|
||||
postTransformProps?: PostTransformProps;
|
||||
}) =>
|
||||
this.postSelector({
|
||||
chartProps: this.transformSelector({
|
||||
chartProps: this.preSelector({ chartProps, preTransformProps }),
|
||||
transformProps,
|
||||
}),
|
||||
postTransformProps,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
<strong>{t('ERROR')}</strong>
|
||||
<code>chartType="{loadingChartType}"</code> —
|
||||
{error.toString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const renderChart = useCallback(
|
||||
(loaded: LoadedModules, props: RenderProps) => {
|
||||
const { Chart, transformProps } = loaded;
|
||||
const {
|
||||
chartProps: renderChartProps,
|
||||
preTransformProps: pre,
|
||||
postTransformProps: post,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Chart
|
||||
{...processChartProps({
|
||||
chartProps: renderChartProps,
|
||||
preTransformProps: pre,
|
||||
transformProps,
|
||||
postTransformProps: post,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[processChartProps],
|
||||
);
|
||||
|
||||
/**
|
||||
* memoized function so it will not recompute
|
||||
* and return previous value
|
||||
* unless one of
|
||||
* - chartType
|
||||
* - overrideTransformProps
|
||||
* is changed.
|
||||
*/
|
||||
const createLoadableRendererSelector = useMemo(
|
||||
() =>
|
||||
createSelector(
|
||||
[
|
||||
(input: {
|
||||
chartType: string;
|
||||
overrideTransformProps?: TransformProps;
|
||||
}) => input.chartType,
|
||||
input => input.overrideTransformProps,
|
||||
],
|
||||
(selectorChartType, selectorOverrideTransformProps) => {
|
||||
if (selectorChartType) {
|
||||
const Renderer = createLoadableRenderer({
|
||||
loader: {
|
||||
Chart: () =>
|
||||
getChartComponentRegistry().getAsPromise(selectorChartType),
|
||||
transformProps: selectorOverrideTransformProps
|
||||
? () => Promise.resolve(selectorOverrideTransformProps)
|
||||
: () =>
|
||||
getChartTransformPropsRegistry().getAsPromise(
|
||||
selectorChartType,
|
||||
),
|
||||
},
|
||||
loading: (loadingProps: LoadingProps) =>
|
||||
renderLoading(loadingProps, selectorChartType),
|
||||
render: renderChart,
|
||||
});
|
||||
|
||||
// Trigger preloading.
|
||||
Renderer.preload();
|
||||
|
||||
return Renderer;
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
/**
|
||||
* memoized function so it will not recompute
|
||||
* and return previous value
|
||||
* unless one of
|
||||
* - chartType
|
||||
* - overrideTransformProps
|
||||
* is changed.
|
||||
*/
|
||||
private createLoadableRenderer = createSelector(
|
||||
[
|
||||
(input: { chartType: string; overrideTransformProps?: TransformProps }) =>
|
||||
input.chartType,
|
||||
input => input.overrideTransformProps,
|
||||
],
|
||||
(chartType, overrideTransformProps) => {
|
||||
if (chartType) {
|
||||
const Renderer = createLoadableRenderer({
|
||||
loader: {
|
||||
Chart: () => getChartComponentRegistry().getAsPromise(chartType),
|
||||
transformProps: overrideTransformProps
|
||||
? () => Promise.resolve(overrideTransformProps)
|
||||
: () => getChartTransformPropsRegistry().getAsPromise(chartType),
|
||||
},
|
||||
),
|
||||
[renderLoading, renderChart],
|
||||
);
|
||||
loading: (loadingProps: LoadingProps) =>
|
||||
this.renderLoading(loadingProps, chartType),
|
||||
render: this.renderChart,
|
||||
});
|
||||
|
||||
const setRef = useCallback((container: HTMLElement | null) => {
|
||||
containerRef.current = container;
|
||||
}, []);
|
||||
// Trigger preloading.
|
||||
Renderer.preload();
|
||||
|
||||
return Renderer;
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
},
|
||||
);
|
||||
|
||||
static defaultProps = defaultProps;
|
||||
|
||||
private renderChart = (loaded: LoadedModules, props: RenderProps) => {
|
||||
const { Chart, transformProps } = loaded;
|
||||
const { chartProps, preTransformProps, postTransformProps } = props;
|
||||
|
||||
return (
|
||||
<Chart
|
||||
{...this.processChartProps({
|
||||
chartProps,
|
||||
preTransformProps,
|
||||
transformProps,
|
||||
postTransformProps,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
private renderLoading = (loadingProps: LoadingProps, chartType: string) => {
|
||||
const { error } = loadingProps;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
<strong>{t('ERROR')}</strong>
|
||||
<code>chartType="{chartType}"</code> —
|
||||
{error.toString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
private setRef = (container: HTMLElement | null) => {
|
||||
this.container = container;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
className,
|
||||
preTransformProps,
|
||||
postTransformProps,
|
||||
chartProps = BLANK_CHART_PROPS,
|
||||
onRenderSuccess,
|
||||
onRenderFailure,
|
||||
} = this.props;
|
||||
|
||||
// Create LoadableRenderer and start preloading
|
||||
// the lazy-loaded Chart components
|
||||
const Renderer = createLoadableRendererSelector({
|
||||
chartType,
|
||||
overrideTransformProps,
|
||||
});
|
||||
const Renderer = this.createLoadableRenderer(this.props);
|
||||
|
||||
// Do not render if chartProps is set to null.
|
||||
// but the pre-loading has been started in createLoadableRendererSelector
|
||||
// but the pre-loading has been started in this.createLoadableRenderer
|
||||
// to prepare for rendering once chartProps becomes available.
|
||||
if (chartProps === null) {
|
||||
return null;
|
||||
@@ -315,7 +263,7 @@ const SuperChartCore = forwardRef<SuperChartCoreRef, Props>(
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...containerProps} ref={setRef}>
|
||||
<div {...containerProps} ref={this.setRef}>
|
||||
<Renderer
|
||||
preTransformProps={preTransformProps}
|
||||
postTransformProps={postTransformProps}
|
||||
@@ -325,7 +273,5 @@ const SuperChartCore = forwardRef<SuperChartCoreRef, Props>(
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default SuperChartCore;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,12 +30,6 @@ export enum VizType {
|
||||
Chord = 'chord',
|
||||
Compare = 'compare',
|
||||
CountryMap = 'country_map',
|
||||
// Modern replacement for CountryMap. Configurable worldview, Admin 0+1
|
||||
// support, regional aggregations, composite maps, modern chart/data
|
||||
// endpoint. Existing dashboards keep using `country_map` until users
|
||||
// explicitly switch via the legacy plugin's "Switch to new chart"
|
||||
// button. See plugin-chart-country-map/SIP_DRAFT.md.
|
||||
CountryMapV2 = 'country_map_v2',
|
||||
Funnel = 'funnel',
|
||||
Gantt = 'gantt_chart',
|
||||
Gauge = 'gauge_chart',
|
||||
|
||||
@@ -38,7 +38,7 @@ export function Label(props: LabelProps) {
|
||||
} = props;
|
||||
|
||||
const baseColor = getColorVariants(theme, type);
|
||||
const color = baseColor.text;
|
||||
const color = baseColor.active;
|
||||
const borderColor = baseColor.border;
|
||||
const backgroundColor = baseColor.bg;
|
||||
|
||||
|
||||
@@ -1160,7 +1160,7 @@ test('does not fire onChange if the same value is selected in single mode', asyn
|
||||
|
||||
// Reference for the bug this tests: https://github.com/apache/superset/pull/33043#issuecomment-2809419640
|
||||
test('typing and deleting the last character for a new option displays correctly', async () => {
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
jest.useFakeTimers();
|
||||
render(<Select {...defaultProps} allowNewOptions />);
|
||||
|
||||
await open();
|
||||
|
||||
@@ -507,7 +507,7 @@ const Select = forwardRef(
|
||||
|
||||
const bulkSelectComponent = useMemo(
|
||||
() => (
|
||||
<StyledBulkActionsContainer justify="center" gap="small" wrap>
|
||||
<StyledBulkActionsContainer justify="space-between">
|
||||
<Button
|
||||
type="link"
|
||||
buttonStyle="link"
|
||||
@@ -519,7 +519,7 @@ const Select = forwardRef(
|
||||
handleSelectAll();
|
||||
}}
|
||||
>
|
||||
{t('Select all')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
|
||||
{`${t('Select all')} (${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
@@ -536,7 +536,7 @@ const Select = forwardRef(
|
||||
handleDeselectAll();
|
||||
}}
|
||||
>
|
||||
{t('Clear')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
|
||||
{`${t('Clear')} (${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
|
||||
</Button>
|
||||
</StyledBulkActionsContainer>
|
||||
),
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen } from '@superset-ui/core/spec';
|
||||
import mockConsole, { RestoreConsole } from 'jest-mock-console';
|
||||
import { triggerResizeObserver } from 'resize-observer-polyfill';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
import { promiseTimeout, SuperChart } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import { WrapperProps } from '../../../src/chart/components/SuperChart';
|
||||
|
||||
import {
|
||||
@@ -65,6 +65,8 @@ function getDimensionText(container: HTMLElement) {
|
||||
describe('SuperChart', () => {
|
||||
jest.setTimeout(5000);
|
||||
|
||||
let restoreConsole: RestoreConsole;
|
||||
|
||||
const plugins = [
|
||||
new DiligentChartPlugin().configure({ key: ChartKeys.DILIGENT }),
|
||||
new BuggyChartPlugin().configure({ key: ChartKeys.BUGGY }),
|
||||
@@ -77,9 +79,14 @@ describe('SuperChart', () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
restoreConsole = mockConsole();
|
||||
triggerResizeObserver([]); // Reset any pending resize observers
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreConsole();
|
||||
});
|
||||
|
||||
describe('includes ErrorBoundary', () => {
|
||||
let expectedErrors = 0;
|
||||
let actualErrors = 0;
|
||||
@@ -111,7 +118,6 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -132,7 +138,6 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
FallbackComponent={CustomFallbackComponent}
|
||||
/>,
|
||||
);
|
||||
@@ -149,7 +154,6 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
onErrorBoundary={handleError}
|
||||
/>,
|
||||
);
|
||||
@@ -174,7 +178,6 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
onErrorBoundary={inactiveErrorHandler}
|
||||
/>
|
||||
</ErrorBoundary>,
|
||||
@@ -202,7 +205,6 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width={101}
|
||||
height={118}
|
||||
theme={supersetTheme}
|
||||
formData={{ abc: 1 }}
|
||||
/>,
|
||||
);
|
||||
@@ -283,7 +285,6 @@ describe('SuperChart', () => {
|
||||
debounceTime={1}
|
||||
width="100%"
|
||||
height="100%"
|
||||
theme={supersetTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -331,7 +332,6 @@ describe('SuperChart', () => {
|
||||
queriesData={DEFAULT_QUERIES_DATA}
|
||||
width={101}
|
||||
height={118}
|
||||
theme={supersetTheme}
|
||||
formData={{ abc: 1 }}
|
||||
/>,
|
||||
);
|
||||
@@ -347,12 +347,7 @@ describe('SuperChart', () => {
|
||||
describe('supports NoResultsComponent', () => {
|
||||
test('renders NoResultsComponent when queriesData is missing', () => {
|
||||
render(
|
||||
<SuperChart
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
/>,
|
||||
<SuperChart chartType={ChartKeys.DILIGENT} width="200" height="200" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('No Results')).toBeInTheDocument();
|
||||
@@ -365,7 +360,6 @@ describe('SuperChart', () => {
|
||||
queriesData={[{ data: null }]}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -393,7 +387,6 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width={100}
|
||||
height={100}
|
||||
theme={supersetTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -418,7 +411,6 @@ describe('SuperChart', () => {
|
||||
debounceTime={1}
|
||||
width="100%"
|
||||
height="100%"
|
||||
theme={supersetTheme}
|
||||
Wrapper={MyWrapper}
|
||||
/>
|
||||
</div>,
|
||||
@@ -483,7 +475,6 @@ describe('SuperChart', () => {
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
queriesData={[{ data: [] }]}
|
||||
enableNoResults
|
||||
/>,
|
||||
@@ -509,7 +500,6 @@ describe('SuperChart', () => {
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
queriesData={[{ data: null }]}
|
||||
enableNoResults
|
||||
/>,
|
||||
@@ -537,7 +527,6 @@ describe('SuperChart', () => {
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
queriesData={[{ data: [] }]}
|
||||
enableNoResults
|
||||
noResults={<CustomNoResults />}
|
||||
@@ -567,7 +556,6 @@ describe('SuperChart', () => {
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
queriesData={[{ data: [] }]}
|
||||
enableNoResults
|
||||
onErrorBoundary={onErrorBoundary}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
import mockConsole, { RestoreConsole } from 'jest-mock-console';
|
||||
import { ChartProps } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import { render, screen, waitFor } from '@superset-ui/core/spec';
|
||||
@@ -37,6 +38,8 @@ describe('SuperChartCore', () => {
|
||||
new SlowChartPlugin().configure({ key: ChartKeys.SLOW }),
|
||||
];
|
||||
|
||||
let restoreConsole: RestoreConsole;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.setTimeout(30000);
|
||||
plugins.forEach(p => {
|
||||
@@ -50,6 +53,14 @@ describe('SuperChartCore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
restoreConsole = mockConsole();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreConsole();
|
||||
});
|
||||
|
||||
describe('registered charts', () => {
|
||||
test('renders registered chart', async () => {
|
||||
const { container } = render(
|
||||
@@ -216,28 +227,15 @@ describe('SuperChartCore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('processChartProps behavior', () => {
|
||||
test('applies identity pre/post transforms so chartProps reach overrideTransformProps unchanged', async () => {
|
||||
// When pre/post transform props are not specified, identity functions are used,
|
||||
// so the original chartProps should reach overrideTransformProps unchanged.
|
||||
// overrideTransformProps is used here as a probe to read the final chartProps;
|
||||
// it's not part of what's being tested for identity behavior.
|
||||
const chartProps2 = new ChartProps({
|
||||
queriesData: [{ message: 'identity-test' }],
|
||||
theme: supersetTheme,
|
||||
describe('.processChartProps()', () => {
|
||||
test('use identity functions for unspecified transforms', () => {
|
||||
const chart = new SuperChartCore({
|
||||
chartType: ChartKeys.DILIGENT,
|
||||
});
|
||||
|
||||
render(
|
||||
<SuperChartCore
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
chartProps={chartProps2}
|
||||
overrideTransformProps={props => props.queriesData[0]}
|
||||
/>,
|
||||
const chartProps2 = new ChartProps();
|
||||
expect(chart.processChartProps({ chartProps: chartProps2 })).toBe(
|
||||
chartProps2,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('identity-test')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
import { ComponentType } from 'react';
|
||||
import mockConsole, { RestoreConsole } from 'jest-mock-console';
|
||||
import { render as renderTestComponent, screen } from '@testing-library/react';
|
||||
import createLoadableRenderer, {
|
||||
LoadableRenderer as LoadableRendererType,
|
||||
@@ -32,8 +33,10 @@ describe('createLoadableRenderer', () => {
|
||||
let render: (loaded: { Chart: ComponentType }) => JSX.Element;
|
||||
let loading: () => JSX.Element;
|
||||
let LoadableRenderer: LoadableRendererType<{}>;
|
||||
let restoreConsole: RestoreConsole;
|
||||
|
||||
beforeEach(() => {
|
||||
restoreConsole = mockConsole();
|
||||
loadChartSuccess = jest.fn(() => Promise.resolve(TestComponent));
|
||||
render = jest.fn(loaded => {
|
||||
const { Chart } = loaded;
|
||||
@@ -51,6 +54,10 @@ describe('createLoadableRenderer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreConsole();
|
||||
});
|
||||
|
||||
describe('returns a LoadableRenderer class', () => {
|
||||
test('LoadableRenderer.preload() preloads the lazy-load components', () => {
|
||||
expect(LoadableRenderer.preload).toBeInstanceOf(Function);
|
||||
|
||||
@@ -18,18 +18,11 @@
|
||||
*/
|
||||
|
||||
/* eslint no-console: 0 */
|
||||
import mockConsole from 'jest-mock-console';
|
||||
import { Registry, OverwritePolicy } from '@superset-ui/core';
|
||||
|
||||
const loader = () => 'testValue';
|
||||
|
||||
const consoleWarnSpy = jest.spyOn(console, 'warn');
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error');
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy.mockClear();
|
||||
consoleWarnSpy.mockClear();
|
||||
});
|
||||
|
||||
describe('Registry', () => {
|
||||
test('exists', () => {
|
||||
expect(Registry !== undefined).toBe(true);
|
||||
@@ -315,15 +308,18 @@ describe('Registry', () => {
|
||||
describe('=ALLOW', () => {
|
||||
describe('.registerValue(key, value)', () => {
|
||||
test('registers normally', () => {
|
||||
const restoreConsole = mockConsole();
|
||||
const registry = new Registry();
|
||||
registry.registerValue('a', 'testValue');
|
||||
expect(() => registry.registerValue('a', 'testValue2')).not.toThrow();
|
||||
expect(registry.get('a')).toEqual('testValue2');
|
||||
expect(console.warn).not.toHaveBeenCalled();
|
||||
restoreConsole();
|
||||
});
|
||||
});
|
||||
describe('.registerLoader(key, loader)', () => {
|
||||
test('registers normally', () => {
|
||||
const restoreConsole = mockConsole();
|
||||
const registry = new Registry();
|
||||
registry.registerLoader('a', () => 'testValue');
|
||||
expect(() =>
|
||||
@@ -331,12 +327,14 @@ describe('Registry', () => {
|
||||
).not.toThrow();
|
||||
expect(registry.get('a')).toEqual('testValue2');
|
||||
expect(console.warn).not.toHaveBeenCalled();
|
||||
restoreConsole();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('=WARN', () => {
|
||||
describe('.registerValue(key, value)', () => {
|
||||
test('warns when overwrite', () => {
|
||||
const restoreConsole = mockConsole();
|
||||
const registry = new Registry({
|
||||
overwritePolicy: OverwritePolicy.Warn,
|
||||
});
|
||||
@@ -344,10 +342,12 @@ describe('Registry', () => {
|
||||
expect(() => registry.registerValue('a', 'testValue2')).not.toThrow();
|
||||
expect(registry.get('a')).toEqual('testValue2');
|
||||
expect(console.warn).toHaveBeenCalled();
|
||||
restoreConsole();
|
||||
});
|
||||
});
|
||||
describe('.registerLoader(key, loader)', () => {
|
||||
test('warns when overwrite', () => {
|
||||
const restoreConsole = mockConsole();
|
||||
const registry = new Registry({
|
||||
overwritePolicy: OverwritePolicy.Warn,
|
||||
});
|
||||
@@ -357,6 +357,7 @@ describe('Registry', () => {
|
||||
).not.toThrow();
|
||||
expect(registry.get('a')).toEqual('testValue2');
|
||||
expect(console.warn).toHaveBeenCalled();
|
||||
restoreConsole();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -437,6 +438,14 @@ describe('Registry', () => {
|
||||
});
|
||||
|
||||
describe('with a broken listener', () => {
|
||||
let restoreConsole: any;
|
||||
beforeEach(() => {
|
||||
restoreConsole = mockConsole();
|
||||
});
|
||||
afterEach(() => {
|
||||
restoreConsole();
|
||||
});
|
||||
|
||||
test('keeps working', () => {
|
||||
const errorListener = jest.fn().mockImplementation(() => {
|
||||
throw new Error('test error');
|
||||
|
||||
@@ -25,108 +25,22 @@
|
||||
*
|
||||
* Run locally:
|
||||
* cd superset-frontend
|
||||
* PLAYWRIGHT_BASE_URL=http://localhost:8088 PLAYWRIGHT_ADMIN_PASSWORD=admin npm run docs:screenshots
|
||||
* npm run docs:screenshots
|
||||
*
|
||||
* Or directly:
|
||||
* npx playwright test --config=playwright/generators/playwright.config.ts docs/
|
||||
*
|
||||
* Screenshots are saved under docs/static/img/.
|
||||
* As new screenshots are scripted, entries are removed from screenshot-manifest.yaml
|
||||
* and the output path moves from that manifest into the test below.
|
||||
* Screenshots are saved to docs/static/img/screenshots/.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { Page } from '@playwright/test';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { URL } from '../../utils/urls';
|
||||
import { apiDelete, apiGet } from '../../helpers/api/requests';
|
||||
|
||||
const DOCS_STATIC = path.resolve(__dirname, '../../../../docs/static/img');
|
||||
const SCREENSHOTS_DIR = path.join(DOCS_STATIC, 'screenshots');
|
||||
const TUTORIAL_DIR = path.join(DOCS_STATIC, 'tutorial');
|
||||
|
||||
/**
|
||||
* Waits for animations and async renders to settle before taking a screenshot.
|
||||
* ECharts entry animations, image lazy-loading, and other async UI updates
|
||||
* require a short pause that can't be expressed as a deterministic wait condition.
|
||||
*/
|
||||
async function settle(page: Page, ms = 1000): Promise<void> {
|
||||
await page.waitForTimeout(ms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to the Sales Dashboard (from example data) and waits for charts
|
||||
* to finish rendering. Used by several tutorial screenshots that show the
|
||||
* dashboard in view or edit mode.
|
||||
*/
|
||||
async function openSalesDashboard(page: Page): Promise<void> {
|
||||
await page.goto(URL.DASHBOARD_LIST);
|
||||
const searchInput = page.getByPlaceholder('Type a value');
|
||||
await expect(searchInput).toBeVisible({ timeout: 15000 });
|
||||
await searchInput.fill('Sales Dashboard');
|
||||
await searchInput.press('Enter');
|
||||
|
||||
const dashboardLink = page.getByRole('link', { name: /sales dashboard/i });
|
||||
await expect(dashboardLink).toBeVisible({ timeout: 10000 });
|
||||
await dashboardLink.click();
|
||||
|
||||
const dashboardWrapper = page.locator(
|
||||
'[data-test="dashboard-content-wrapper"]',
|
||||
);
|
||||
await expect(dashboardWrapper).toBeVisible({ timeout: 30000 });
|
||||
await expect(
|
||||
page.locator('.dashboard-component-chart-holder').first(),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
await expect(
|
||||
dashboardWrapper.locator('[data-test="loading-indicator"]'),
|
||||
).toHaveCount(0, { timeout: 30000 });
|
||||
await expect(
|
||||
page.locator('.dashboard-component-chart-holder canvas').first(),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all dashboards matching the given exact title, along with the
|
||||
* charts attached to them. Used by the save-flow test to clean up after
|
||||
* itself and to recover from prior failed runs (idempotent pre-cleanup).
|
||||
*
|
||||
* Only safe because the title is unique to the test ("Superset Duper
|
||||
* Sales Dashboard"); don't reuse this against titles that could match
|
||||
* example-data dashboards.
|
||||
*/
|
||||
async function deleteDashboardByTitle(
|
||||
page: Page,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
const filter = `(filters:!((col:dashboard_title,opr:eq,value:'${title}')))`;
|
||||
const resp = await apiGet(page, 'api/v1/dashboard/', {
|
||||
params: { q: filter },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
if (!resp.ok()) return;
|
||||
const body = await resp.json();
|
||||
const dashboards: { id: number }[] = body.result || [];
|
||||
|
||||
for (const dash of dashboards) {
|
||||
const chartsResp = await apiGet(
|
||||
page,
|
||||
`api/v1/dashboard/${dash.id}/charts`,
|
||||
{ failOnStatusCode: false },
|
||||
);
|
||||
const chartIds: number[] = chartsResp.ok()
|
||||
? ((await chartsResp.json()).result || [])
|
||||
.map((c: { id?: number }) => c.id)
|
||||
.filter((id: unknown): id is number => typeof id === 'number')
|
||||
: [];
|
||||
|
||||
await apiDelete(page, `api/v1/dashboard/${dash.id}`, {
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
for (const id of chartIds) {
|
||||
await apiDelete(page, `api/v1/chart/${id}`, { failOnStatusCode: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
const SCREENSHOTS_DIR = path.resolve(
|
||||
__dirname,
|
||||
'../../../../docs/static/img/screenshots',
|
||||
);
|
||||
|
||||
test('chart gallery screenshot', async ({ page }) => {
|
||||
await page.goto(URL.CHART_ADD);
|
||||
@@ -144,7 +58,6 @@ test('chart gallery screenshot', async ({ page }) => {
|
||||
vizGallery.locator('[data-test="viztype-selector-container"]').first(),
|
||||
).toBeVisible();
|
||||
|
||||
await settle(page);
|
||||
await vizGallery.screenshot({
|
||||
path: path.join(SCREENSHOTS_DIR, 'gallery.jpg'),
|
||||
type: 'jpeg',
|
||||
@@ -152,7 +65,36 @@ test('chart gallery screenshot', async ({ page }) => {
|
||||
});
|
||||
|
||||
test('dashboard screenshot', async ({ page }) => {
|
||||
await openSalesDashboard(page);
|
||||
// Navigate to Sales Dashboard via the dashboard list (slug is null)
|
||||
await page.goto(URL.DASHBOARD_LIST);
|
||||
const searchInput = page.getByPlaceholder('Type a value');
|
||||
await expect(searchInput).toBeVisible({ timeout: 15000 });
|
||||
await searchInput.fill('Sales Dashboard');
|
||||
await searchInput.press('Enter');
|
||||
|
||||
// Click the Sales Dashboard link
|
||||
const dashboardLink = page.getByRole('link', { name: /sales dashboard/i });
|
||||
await expect(dashboardLink).toBeVisible({ timeout: 10000 });
|
||||
await dashboardLink.click();
|
||||
|
||||
// Wait for dashboard to fully render
|
||||
const dashboardWrapper = page.locator(
|
||||
'[data-test="dashboard-content-wrapper"]',
|
||||
);
|
||||
await expect(dashboardWrapper).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Wait for chart holders to appear, then wait for all loading spinners to clear
|
||||
await expect(
|
||||
page.locator('.dashboard-component-chart-holder').first(),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
await expect(
|
||||
dashboardWrapper.locator('[data-test="loading-indicator"]'),
|
||||
).toHaveCount(0, { timeout: 30000 });
|
||||
|
||||
// Wait for at least one chart to finish rendering (ECharts renders to canvas)
|
||||
await expect(
|
||||
page.locator('.dashboard-component-chart-holder canvas').first(),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Open the filter bar (collapsed by default)
|
||||
const expandButton = page.locator('[data-test="filter-bar__expand-button"]');
|
||||
@@ -167,8 +109,6 @@ test('dashboard screenshot', async ({ page }) => {
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
// Allow ECharts entry animations to finish before capturing
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: path.join(SCREENSHOTS_DIR, 'dashboard.jpg'),
|
||||
type: 'jpeg',
|
||||
@@ -203,7 +143,6 @@ test('chart editor screenshot', async ({ page }) => {
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: path.join(SCREENSHOTS_DIR, 'explore.jpg'),
|
||||
type: 'jpeg',
|
||||
@@ -212,7 +151,7 @@ test('chart editor screenshot', async ({ page }) => {
|
||||
});
|
||||
|
||||
test('SQL Lab screenshot', async ({ page }) => {
|
||||
// SQL Lab has many interactive steps — allow extra time
|
||||
// SQL Lab has many interactive steps (schema, table, query, results) — allow extra time
|
||||
test.setTimeout(90000);
|
||||
await page.goto(URL.SQLLAB);
|
||||
|
||||
@@ -229,7 +168,34 @@ test('SQL Lab screenshot', async ({ page }) => {
|
||||
}
|
||||
await expect(aceEditor).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Click the active query tab to ensure focus is on the editor pane
|
||||
// Select the "public" schema so we can pick a table from the left panel
|
||||
const schemaSelect = page.locator('#select-schema');
|
||||
await expect(schemaSelect).toBeEnabled({ timeout: 10000 });
|
||||
await schemaSelect.click({ force: true });
|
||||
await schemaSelect.fill('public');
|
||||
await page.getByRole('option', { name: 'public' }).click();
|
||||
|
||||
// Wait for table list to load after schema change, then select birth_names
|
||||
const tableSelectWrapper = page
|
||||
.locator('.ant-select')
|
||||
.filter({ has: page.locator('#select-table') });
|
||||
await expect(tableSelectWrapper).toBeVisible({ timeout: 10000 });
|
||||
await tableSelectWrapper.click();
|
||||
await page.keyboard.type('birth_names');
|
||||
// Wait for the filtered option to appear in the DOM, then select it
|
||||
const tableOption = page
|
||||
.locator('.ant-select-dropdown [role="option"]')
|
||||
.filter({ hasText: 'birth_names' });
|
||||
await expect(tableOption).toBeAttached({ timeout: 10000 });
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Wait for table schema to load and show columns in the left panel
|
||||
await expect(page.locator('[data-test="col-name"]').first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// Close the table dropdown by clicking elsewhere, then switch to the query tab
|
||||
await page.locator('[data-test="sql-editor-tabs"]').first().click();
|
||||
await page.getByText('Untitled Query').first().click();
|
||||
|
||||
// Write a multi-line SELECT with explicit columns to fill the editor
|
||||
@@ -239,8 +205,8 @@ test('SQL Lab screenshot', async ({ page }) => {
|
||||
'SELECT\n ds,\n name,\n gender,\n state,\n num\nFROM birth_names\nLIMIT 100',
|
||||
);
|
||||
|
||||
// Run the query — use the stable data-test attribute on the action button
|
||||
const runButton = page.locator('[data-test="run-query-action"]');
|
||||
// Run the query
|
||||
const runButton = page.getByText('Run', { exact: true });
|
||||
await expect(runButton).toBeVisible();
|
||||
await runButton.click();
|
||||
|
||||
@@ -256,352 +222,9 @@ test('SQL Lab screenshot', async ({ page }) => {
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.getByRole('tooltip')).toHaveCount(0, { timeout: 2000 });
|
||||
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: path.join(SCREENSHOTS_DIR, 'sql_lab.jpg'),
|
||||
type: 'jpeg',
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tutorial screenshots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('datasets list screenshot', async ({ page }) => {
|
||||
await page.goto(URL.DATASET_LIST);
|
||||
|
||||
const table = page.locator('[data-test="listview-table"]');
|
||||
await expect(table).toBeVisible({ timeout: 15000 });
|
||||
// Wait for at least one visible data row (skip ant-table-measure-row which is always hidden)
|
||||
await expect(
|
||||
table.locator('tbody tr:not(.ant-table-measure-row)').first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Viewport screenshot (not fullPage) captures the SubMenu — showing the
|
||||
// "Datasets" nav item, Bulk Select button, and + Dataset button — plus the
|
||||
// top of the table. This is more informative than screenshotting the table alone.
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: path.join(TUTORIAL_DIR, 'tutorial_08_sources_tables.png'),
|
||||
type: 'png',
|
||||
});
|
||||
});
|
||||
|
||||
test('chart type picker screenshot', async ({ page }) => {
|
||||
await page.goto(URL.CHART_ADD);
|
||||
|
||||
// Wait for the dataset step to appear (step title is first match; placeholder is second)
|
||||
await expect(page.getByText('Choose a dataset').first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// Open the dataset selector and choose birth_names
|
||||
await page.getByTestId('Dataset').click();
|
||||
await page.keyboard.type('birth_names');
|
||||
// The dataset select uses a hidden ARIA listbox — the visible popup is a portal.
|
||||
// Wait for the first option to appear in the DOM, then select it via keyboard.
|
||||
await expect(
|
||||
page.locator('[role="listbox"] [role="option"]').first(),
|
||||
).toBeAttached({ timeout: 10000 });
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Open the chart gallery and wait for thumbnails to render
|
||||
await expect(page.getByText('Choose chart type')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.getByRole('tab', { name: 'All charts' }).click();
|
||||
const vizGallery = page.locator('.viz-gallery');
|
||||
await expect(vizGallery).toBeVisible();
|
||||
await expect(
|
||||
vizGallery.locator('[data-test="viztype-selector-container"]').first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Select the Pivot Table chart type
|
||||
await vizGallery
|
||||
.locator('[data-test="viztype-selector-container"]')
|
||||
.filter({ hasText: 'Pivot Table' })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Allow thumbnails to finish loading and selection state to render
|
||||
await settle(page);
|
||||
|
||||
// Viewport screenshot shows the dataset step (birth_names selected) and
|
||||
// the chart type gallery (Pivot Table highlighted)
|
||||
await page.screenshot({
|
||||
path: path.join(TUTORIAL_DIR, 'create_pivot.png'),
|
||||
type: 'png',
|
||||
});
|
||||
});
|
||||
|
||||
test('publish button dashboard screenshot', async ({ page }) => {
|
||||
// Toggle Sales Dashboard to Draft, hover the label so the tooltip renders,
|
||||
// then capture the header area plus enough room below for the tooltip.
|
||||
// Always restores the dashboard to Published at the end.
|
||||
await openSalesDashboard(page);
|
||||
|
||||
const publishedLabel = page.getByText('Published', { exact: true }).first();
|
||||
await expect(publishedLabel).toBeVisible({ timeout: 10000 });
|
||||
await publishedLabel.click();
|
||||
|
||||
const draftLabel = page.getByText('Draft', { exact: true }).first();
|
||||
await expect(draftLabel).toBeVisible({ timeout: 10000 });
|
||||
|
||||
try {
|
||||
await draftLabel.hover();
|
||||
await expect(page.getByRole('tooltip')).toBeVisible({ timeout: 5000 });
|
||||
await settle(page, 500);
|
||||
|
||||
const headerBox = await page
|
||||
.locator('[data-test="dashboard-header-container"]')
|
||||
.boundingBox();
|
||||
if (!headerBox) {
|
||||
throw new Error('Could not locate dashboard header container');
|
||||
}
|
||||
await page.screenshot({
|
||||
path: path.join(TUTORIAL_DIR, 'publish_button_dashboard.png'),
|
||||
type: 'png',
|
||||
clip: {
|
||||
x: headerBox.x,
|
||||
y: headerBox.y,
|
||||
width: headerBox.width,
|
||||
height: headerBox.height + 140,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
// Restore: click Draft to re-publish so other runs start from a clean state
|
||||
await page.mouse.move(0, 0);
|
||||
await draftLabel.click();
|
||||
await expect(
|
||||
page.getByText('Published', { exact: true }).first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('edit button screenshot', async ({ page }) => {
|
||||
// Capture the right-side action buttons (Edit dashboard + "..." menu)
|
||||
// rather than the edit button in isolation.
|
||||
await openSalesDashboard(page);
|
||||
await settle(page);
|
||||
|
||||
const rightPanel = page.locator('.right-button-panel');
|
||||
await expect(rightPanel).toBeVisible({ timeout: 5000 });
|
||||
await rightPanel.screenshot({
|
||||
path: path.join(TUTORIAL_DIR, 'tutorial_edit_button.png'),
|
||||
type: 'png',
|
||||
});
|
||||
});
|
||||
|
||||
test('chart resize screenshot', async ({ page }) => {
|
||||
// Enter edit mode, start a resize drag on the right-edge handle, then
|
||||
// screenshot the chart mid-drag. While `DashboardGrid` is in the resizing
|
||||
// state it renders vertical `grid-column-guide` overlays across the grid
|
||||
// and the chart gets a blue `--resizing` outline — that's the state the
|
||||
// original tutorial screenshot was capturing.
|
||||
await openSalesDashboard(page);
|
||||
|
||||
const editButton = page.locator('[data-test="edit-dashboard-button"]');
|
||||
await expect(editButton).toBeVisible();
|
||||
await editButton.click();
|
||||
|
||||
await expect(
|
||||
page.locator('[data-test="dashboard-builder-sidepane"]'),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const chart = page.locator('.dashboard-component-chart-holder').first();
|
||||
await expect(chart).toBeVisible();
|
||||
const chartBox = await chart.boundingBox();
|
||||
if (!chartBox) {
|
||||
throw new Error('Could not locate chart bounding box');
|
||||
}
|
||||
|
||||
// Hover over the chart so the on-hover action buttons (drag/trash/settings)
|
||||
// and resize handles become visible.
|
||||
await page.mouse.move(
|
||||
chartBox.x + chartBox.width / 2,
|
||||
chartBox.y + chartBox.height / 2,
|
||||
);
|
||||
await settle(page, 200);
|
||||
|
||||
// The right-edge handle is a `<span>` added by re-resizable with our
|
||||
// custom class. Locating it by class is more reliable than computing
|
||||
// coordinates from the chart-holder (which isn't the full resizable box).
|
||||
const rightHandle = page
|
||||
.locator('.resizable-container-handle--right')
|
||||
.first();
|
||||
await expect(rightHandle).toBeVisible();
|
||||
const handleBox = await rightHandle.boundingBox();
|
||||
if (!handleBox) {
|
||||
throw new Error('Could not locate right-edge resize handle');
|
||||
}
|
||||
const handleX = handleBox.x + handleBox.width / 2;
|
||||
const handleY = handleBox.y + handleBox.height / 2;
|
||||
|
||||
await page.mouse.move(handleX, handleY);
|
||||
await page.mouse.down();
|
||||
// Move far enough to snap at least one grid column, which puts
|
||||
// DashboardGrid into isResizing=true so the column guides render.
|
||||
await page.mouse.move(handleX + 80, handleY, { steps: 10 });
|
||||
await settle(page, 500);
|
||||
|
||||
// Clip to the chart area plus a left gutter for the hover action rail
|
||||
// and right padding that reaches past the dragged handle position.
|
||||
const leftGutter = 32;
|
||||
const rightPadding = 100;
|
||||
const topPadding = 16;
|
||||
const bottomPadding = 24;
|
||||
await page.screenshot({
|
||||
path: path.join(TUTORIAL_DIR, 'tutorial_chart_resize.png'),
|
||||
type: 'png',
|
||||
clip: {
|
||||
x: Math.max(0, chartBox.x - leftGutter),
|
||||
y: Math.max(0, chartBox.y - topPadding),
|
||||
width: chartBox.width + leftGutter + rightPadding,
|
||||
height: chartBox.height + topPadding + bottomPadding,
|
||||
},
|
||||
});
|
||||
|
||||
// Release back at the start to avoid persisting a size change. Edit-mode
|
||||
// changes aren't saved (we never click the dashboard Save button).
|
||||
await page.mouse.move(handleX, handleY, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
});
|
||||
|
||||
test('save flow and first dashboard screenshots', async ({ page }) => {
|
||||
// Captures two linked tutorial screenshots in a single flow so the second
|
||||
// faithfully shows the dashboard the user just created:
|
||||
// 1. tutorial_save_slice.png — Save modal with the "Add to dashboard"
|
||||
// dropdown surfacing a creatable option for a new dashboard.
|
||||
// 2. tutorial_first_dashboard.png — the freshly-created dashboard with
|
||||
// the single saved chart (matches the tutorial narrative).
|
||||
//
|
||||
// Creates and then deletes a "Superset Duper Sales Dashboard" dashboard
|
||||
// plus the duplicate chart it owns. Pre-cleans in case a prior run failed.
|
||||
const NEW_DASHBOARD_NAME = 'Superset Duper Sales Dashboard';
|
||||
await deleteDashboardByTitle(page, NEW_DASHBOARD_NAME);
|
||||
|
||||
// 1100px is wide enough to show the full "Superset Duper Sales Dashboard"
|
||||
// title alongside the header actions without truncation.
|
||||
await page.setViewportSize({ width: 1100, height: 800 });
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
const searchInput = page.getByPlaceholder('Type a value');
|
||||
await expect(searchInput).toBeVisible({ timeout: 15000 });
|
||||
await searchInput.fill('Scatter Plot');
|
||||
await searchInput.press('Enter');
|
||||
|
||||
const chartLink = page.getByRole('link', { name: /scatter plot/i });
|
||||
await expect(chartLink).toBeVisible({ timeout: 10000 });
|
||||
await chartLink.click();
|
||||
|
||||
await page.waitForURL('**/explore/**', { timeout: 15000 });
|
||||
const sliceContainer = page.locator('[data-test="slice-container"]');
|
||||
await expect(sliceContainer).toBeVisible({ timeout: 15000 });
|
||||
await expect(
|
||||
sliceContainer.locator('[data-test="loading-indicator"]'),
|
||||
).toHaveCount(0, { timeout: 15000 });
|
||||
|
||||
const saveButton = page.locator('[data-test="query-save-button"]');
|
||||
await expect(saveButton).toBeVisible({ timeout: 10000 });
|
||||
await saveButton.click();
|
||||
|
||||
const modal = page.locator('.ant-modal-content').filter({
|
||||
has: page.locator('[data-test="save-modal-body"]'),
|
||||
});
|
||||
await expect(modal).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Open the "Add to dashboard" select and type a new dashboard name so
|
||||
// the dropdown surfaces the creatable option.
|
||||
const dashboardSelect = page.getByRole('combobox', {
|
||||
name: /select a dashboard/i,
|
||||
});
|
||||
await dashboardSelect.click();
|
||||
await page.keyboard.type(NEW_DASHBOARD_NAME);
|
||||
|
||||
// Ant Design portals the visible dropdown with the class
|
||||
// `.ant-select-item-option` on each option (distinct from the hidden
|
||||
// ARIA listbox options rendered inside the combobox itself).
|
||||
const createOption = page
|
||||
.locator('.ant-select-item-option')
|
||||
.filter({ hasText: NEW_DASHBOARD_NAME });
|
||||
await expect(createOption).toBeVisible({ timeout: 10000 });
|
||||
await settle(page);
|
||||
|
||||
try {
|
||||
// Screenshot 1: save modal + portaled dropdown.
|
||||
const modalBox = await modal.boundingBox();
|
||||
const optionBox = await createOption.boundingBox();
|
||||
if (!modalBox || !optionBox) {
|
||||
throw new Error('Could not locate save modal or create-option');
|
||||
}
|
||||
const padding = 16;
|
||||
const top = Math.max(0, modalBox.y - padding);
|
||||
const bottom = optionBox.y + optionBox.height + padding;
|
||||
await page.screenshot({
|
||||
path: path.join(TUTORIAL_DIR, 'tutorial_save_slice.png'),
|
||||
type: 'png',
|
||||
clip: {
|
||||
x: Math.max(0, modalBox.x - padding),
|
||||
y: top,
|
||||
width: modalBox.width + padding * 2,
|
||||
height: bottom - top,
|
||||
},
|
||||
});
|
||||
|
||||
// Pick the creatable option, then click "Save & go to dashboard" so the
|
||||
// backend creates the dashboard + slice and redirects us to the new one.
|
||||
await createOption.click();
|
||||
const saveAndGotoBtn = page.locator('#btn_modal_save_goto_dash');
|
||||
await expect(saveAndGotoBtn).toBeEnabled({ timeout: 5000 });
|
||||
await saveAndGotoBtn.click();
|
||||
|
||||
await page.waitForURL(/\/dashboard\/[^/]+\/?/, { timeout: 30000 });
|
||||
await expect(
|
||||
page.locator('[data-test="dashboard-content-wrapper"]'),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
await expect(
|
||||
page.locator('.dashboard-component-chart-holder').first(),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
await expect(
|
||||
page.locator('.dashboard-component-chart-holder canvas').first(),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Dismiss the "Chart [X] has been saved" toast so it doesn't appear in
|
||||
// the screenshot. The close button is inside the toast container.
|
||||
const toast = page.locator('[data-test="toast-container"]').first();
|
||||
if (await toast.isVisible().catch(() => false)) {
|
||||
await toast.locator('.toast__close').click();
|
||||
await expect(toast).toBeHidden({ timeout: 5000 });
|
||||
}
|
||||
await settle(page);
|
||||
|
||||
// Screenshot 2: the newly-created single-chart dashboard (title + chart).
|
||||
const headerBox = await page
|
||||
.locator('[data-test="dashboard-header-wrapper"]')
|
||||
.boundingBox();
|
||||
const chartBox = await page
|
||||
.locator('.dashboard-component-chart-holder')
|
||||
.first()
|
||||
.boundingBox();
|
||||
if (!headerBox || !chartBox) {
|
||||
throw new Error('Could not locate dashboard header or chart');
|
||||
}
|
||||
// Trim right edge to just past the chart so the screenshot isn't padded
|
||||
// with empty grid space.
|
||||
const rightPadding = 16;
|
||||
await page.screenshot({
|
||||
path: path.join(TUTORIAL_DIR, 'tutorial_first_dashboard.png'),
|
||||
type: 'png',
|
||||
clip: {
|
||||
x: 0,
|
||||
y: headerBox.y,
|
||||
width: Math.min(1100, chartBox.x + chartBox.width + rightPadding),
|
||||
height: chartBox.y + chartBox.height - headerBox.y + 16,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await deleteDashboardByTitle(page, NEW_DASHBOARD_NAME);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -64,7 +64,6 @@ export default defineConfig({
|
||||
name: 'docs-generators',
|
||||
use: {
|
||||
browserName: 'chromium',
|
||||
baseURL, // explicit here so globalSetup can read it from config.projects[0].use.baseURL
|
||||
testIdAttribute: 'data-test',
|
||||
storageState: path.resolve(__dirname, '../.auth/user.json'),
|
||||
},
|
||||
|
||||
@@ -1,220 +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 { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { apiPost, apiPut } from '../../helpers/api/requests';
|
||||
import { apiPostDashboard } from '../../helpers/api/dashboard';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
const FILTER_COLUMN = 'gender';
|
||||
|
||||
async function findDatasetIdByName(page: any, name: string): Promise<number> {
|
||||
const rison = `(filters:!((col:table_name,opr:eq,value:'${name}')))`;
|
||||
const resp = await page.request.get(`api/v1/dataset/?q=${rison}`);
|
||||
const body = await resp.json();
|
||||
if (!body.result?.length) {
|
||||
throw new Error(`Dataset ${name} not found`);
|
||||
}
|
||||
return body.result[0].id;
|
||||
}
|
||||
|
||||
testWithAssets(
|
||||
'Clear all filters waits for Apply (sc-105059)',
|
||||
async ({ page, testAssets }) => {
|
||||
const datasetId = await findDatasetIdByName(page, DATASET_NAME);
|
||||
|
||||
// Create a chart that the dashboard filter will target
|
||||
const chartParams = {
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'big_number_total',
|
||||
metric: 'count',
|
||||
adhoc_filters: [],
|
||||
header_font_size: 0.4,
|
||||
subheader_font_size: 0.15,
|
||||
};
|
||||
const chartResp = await apiPost(page, 'api/v1/chart/', {
|
||||
slice_name: `clear_all_repro_${Date.now()}`,
|
||||
viz_type: 'big_number_total',
|
||||
datasource_id: datasetId,
|
||||
datasource_type: 'table',
|
||||
params: JSON.stringify(chartParams),
|
||||
});
|
||||
expect(chartResp.ok()).toBe(true);
|
||||
const chart = await chartResp.json();
|
||||
const chartId: number = chart.id ?? chart.result?.id;
|
||||
testAssets.trackChart(chartId);
|
||||
|
||||
// Create dashboard with chart in position_json and a native filter in json_metadata
|
||||
const filterId = `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const chartLayoutKey = `CHART-${chartId}`;
|
||||
const positionJson = {
|
||||
DASHBOARD_VERSION_KEY: 'v2',
|
||||
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
|
||||
GRID_ID: {
|
||||
type: 'GRID',
|
||||
id: 'GRID_ID',
|
||||
children: ['ROW-1'],
|
||||
parents: ['ROOT_ID'],
|
||||
},
|
||||
'ROW-1': {
|
||||
type: 'ROW',
|
||||
id: 'ROW-1',
|
||||
children: [chartLayoutKey],
|
||||
parents: ['ROOT_ID', 'GRID_ID'],
|
||||
meta: { background: 'BACKGROUND_TRANSPARENT' },
|
||||
},
|
||||
[chartLayoutKey]: {
|
||||
type: 'CHART',
|
||||
id: chartLayoutKey,
|
||||
children: [],
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
|
||||
meta: {
|
||||
chartId,
|
||||
width: 6,
|
||||
height: 50,
|
||||
sliceName: 'clear_all_repro',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const jsonMetadata = {
|
||||
native_filter_configuration: [
|
||||
{
|
||||
id: filterId,
|
||||
name: 'Gender',
|
||||
filterType: 'filter_select',
|
||||
type: 'NATIVE_FILTER',
|
||||
targets: [
|
||||
{
|
||||
datasetId,
|
||||
column: { name: FILTER_COLUMN },
|
||||
},
|
||||
],
|
||||
controlValues: {
|
||||
multiSelect: false,
|
||||
enableEmptyFilter: false,
|
||||
defaultToFirstItem: false,
|
||||
inverseSelection: false,
|
||||
searchAllOptions: false,
|
||||
},
|
||||
defaultDataMask: { filterState: {}, extraFormData: {} },
|
||||
cascadeParentIds: [],
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
chartsInScope: [chartId],
|
||||
},
|
||||
],
|
||||
chart_configuration: {},
|
||||
cross_filters_enabled: false,
|
||||
global_chart_configuration: {
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
chartsInScope: [chartId],
|
||||
},
|
||||
};
|
||||
|
||||
const dashResp = await apiPostDashboard(page, {
|
||||
dashboard_title: `clear_all_repro_${Date.now()}`,
|
||||
published: true,
|
||||
position_json: JSON.stringify(positionJson),
|
||||
json_metadata: JSON.stringify(jsonMetadata),
|
||||
});
|
||||
expect(dashResp.ok()).toBe(true);
|
||||
const dashBody = await dashResp.json();
|
||||
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
|
||||
testAssets.trackDashboard(dashboardId);
|
||||
|
||||
// Associate chart with the dashboard so it actually renders
|
||||
const linkResp = await apiPut(page, `api/v1/chart/${chartId}`, {
|
||||
dashboards: [dashboardId],
|
||||
});
|
||||
expect(linkResp.ok()).toBe(true);
|
||||
|
||||
// Visit dashboard
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.gotoById(dashboardId);
|
||||
await dashboardPage.waitForLoad();
|
||||
await dashboardPage.waitForChartsToLoad();
|
||||
|
||||
// The Gender select should be visible in the filter bar
|
||||
const filterCombobox = page
|
||||
.locator('[data-test="form-item-value"]')
|
||||
.first()
|
||||
.locator('[role="combobox"]');
|
||||
await filterCombobox.click();
|
||||
await page
|
||||
.locator('.ant-select-item-option', { hasText: /^boy$/ })
|
||||
.first()
|
||||
.click();
|
||||
// Close the dropdown
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const applyBtn = page.locator(
|
||||
'[data-test="filter-bar__apply-button"], [data-test="filterbar-action-buttons"] button[type="submit"]',
|
||||
);
|
||||
|
||||
// Wait for chart data to come back after Apply
|
||||
const firstApplyResponse = page.waitForResponse(
|
||||
r =>
|
||||
r.url().includes('/api/v1/chart/data') &&
|
||||
r.request().method() === 'POST',
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await applyBtn.first().click();
|
||||
await firstApplyResponse;
|
||||
await dashboardPage.waitForChartsToLoad();
|
||||
|
||||
// Now track POST /api/v1/chart/data requests around Clear All
|
||||
const postsAfterClearAll: string[] = [];
|
||||
const handler = (req: any) => {
|
||||
if (
|
||||
req.url().includes('/api/v1/chart/data') &&
|
||||
req.method() === 'POST'
|
||||
) {
|
||||
postsAfterClearAll.push(req.url());
|
||||
}
|
||||
};
|
||||
page.on('request', handler);
|
||||
|
||||
const clearBtn = page.locator('[data-test="filter-bar__clear-button"]');
|
||||
await clearBtn.click();
|
||||
|
||||
// Allow time for any debounced reload to fire if the bug is present
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
page.off('request', handler);
|
||||
|
||||
// BUG: on master, the Clear All triggers an immediate dispatch which
|
||||
// re-runs the chart query before the user clicks Apply. After the fix,
|
||||
// no chart/data request should fire until Apply is clicked.
|
||||
expect(
|
||||
postsAfterClearAll,
|
||||
'Clear All must not reload charts until Apply is clicked',
|
||||
).toEqual([]);
|
||||
|
||||
// After Apply, the chart should reload
|
||||
const applyAfterClearPromise = page.waitForResponse(
|
||||
r =>
|
||||
r.url().includes('/api/v1/chart/data') &&
|
||||
r.request().method() === 'POST',
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await applyBtn.first().click();
|
||||
await applyAfterClearPromise;
|
||||
},
|
||||
);
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ChartLabel, ChartMetadata, ChartPlugin } from '@superset-ui/core';
|
||||
import { ChartMetadata, ChartPlugin } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
import exampleUsa from './images/exampleUsa.jpg';
|
||||
import exampleUsaDark from './images/exampleUsa-dark.jpg';
|
||||
@@ -37,18 +37,7 @@ const metadata = new ChartMetadata({
|
||||
{ url: exampleUsa, urlDark: exampleUsaDark },
|
||||
{ url: exampleGermany, urlDark: exampleGermanyDark },
|
||||
],
|
||||
name: t('Country Map (Legacy)'),
|
||||
// DEPRECATED: replaced by `country_map_v2` (plugin-chart-country-map).
|
||||
// Existing dashboards continue to render against this plugin until users
|
||||
// explicitly switch via the "Switch to new chart" button. Schedule:
|
||||
// see SIP_DRAFT.md in the new plugin's directory.
|
||||
label: ChartLabel.Deprecated,
|
||||
labelExplanation: t(
|
||||
'Replaced by the new Country Map chart, which uses the modern chart/data ' +
|
||||
'endpoint, supports configurable worldview for disputed regions, and adds ' +
|
||||
'composite + aggregated regional layers. Existing charts using this type ' +
|
||||
'continue to work; new charts should use the modern Country Map.',
|
||||
),
|
||||
name: t('Country Map'),
|
||||
tags: [
|
||||
t('2D'),
|
||||
t('Comparison'),
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"classnames": "^2.5.1",
|
||||
"d3-array": "^3.2.4",
|
||||
"lodash": "^4.18.1",
|
||||
"memoize-one": "^6.0.0",
|
||||
"memoize-one": "^5.2.1",
|
||||
"react-table": "^7.8.0",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"xss": "^1.0.15"
|
||||
|
||||
@@ -494,12 +494,6 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Visual formatting'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'column_config',
|
||||
@@ -593,12 +587,18 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Visual formatting'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'show_cell_bars',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show cell bars for all columns'),
|
||||
label: t('Show cell bars'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t(
|
||||
@@ -612,7 +612,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'align_pn',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Align +/- for all columns'),
|
||||
label: t('Align +/-'),
|
||||
renderTrigger: true,
|
||||
default: false,
|
||||
description: t(
|
||||
@@ -626,7 +626,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'color_pn',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Add colors to cell bars for +/- for all columns'),
|
||||
label: t('Add colors to cell bars for +/-'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t(
|
||||
|
||||
@@ -1,83 +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 { ValueGetterParams } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import htmlTextFilterValueGetter, {
|
||||
htmlTextComparator,
|
||||
} from './htmlTextFilterValueGetter';
|
||||
|
||||
const makeParams = (value: unknown): ValueGetterParams =>
|
||||
({
|
||||
data: { foo: value },
|
||||
colDef: { field: 'foo' },
|
||||
}) as unknown as ValueGetterParams;
|
||||
|
||||
test('htmlTextFilterValueGetter extracts visible text from HTML anchor', () => {
|
||||
expect(
|
||||
htmlTextFilterValueGetter(
|
||||
makeParams(
|
||||
'<a href="https://jira.example.com/123/S18_3232">S18_3232</a>',
|
||||
),
|
||||
),
|
||||
).toBe('S18_3232');
|
||||
});
|
||||
|
||||
test('htmlTextFilterValueGetter strips nested HTML markup', () => {
|
||||
expect(
|
||||
htmlTextFilterValueGetter(
|
||||
makeParams('<div><strong>Hello</strong> <em>World</em></div>'),
|
||||
),
|
||||
).toBe('Hello World');
|
||||
});
|
||||
|
||||
test('htmlTextFilterValueGetter passes plain strings through', () => {
|
||||
expect(htmlTextFilterValueGetter(makeParams('plain value'))).toBe(
|
||||
'plain value',
|
||||
);
|
||||
});
|
||||
|
||||
test('htmlTextFilterValueGetter passes non-string values through', () => {
|
||||
expect(htmlTextFilterValueGetter(makeParams(42))).toBe(42);
|
||||
expect(htmlTextFilterValueGetter(makeParams(null))).toBeNull();
|
||||
expect(htmlTextFilterValueGetter(makeParams(undefined))).toBeUndefined();
|
||||
});
|
||||
|
||||
test('htmlTextComparator orders by visible text, not raw HTML', () => {
|
||||
// URL prefixes (zzz vs bbb) would flip the order under raw-HTML sort,
|
||||
// but the visible labels (S700_4002 vs S72_3212) sort the other way.
|
||||
const left = '<a href="https://jira.example.com/zzz/S700_4002">S700_4002</a>';
|
||||
const right = '<a href="https://jira.example.com/bbb/S72_3212">S72_3212</a>';
|
||||
expect(htmlTextComparator(left, right)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test('htmlTextComparator handles nulls and numbers', () => {
|
||||
expect(htmlTextComparator(null, null)).toBe(0);
|
||||
expect(htmlTextComparator(null, 'x')).toBeLessThan(0);
|
||||
expect(htmlTextComparator('x', null)).toBeGreaterThan(0);
|
||||
expect(htmlTextComparator(1, 2)).toBeLessThan(0);
|
||||
expect(htmlTextComparator(2, 1)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('htmlTextComparator preserves default codepoint ordering for plain strings', () => {
|
||||
// AG Grid's default string comparator orders by codepoint, so 'Z' (90)
|
||||
// sorts before 'a' (97). A locale-aware comparator would flip this —
|
||||
// verify we match the default so plain string columns are unaffected.
|
||||
expect(htmlTextComparator('Z', 'a')).toBeLessThan(0);
|
||||
expect(htmlTextComparator('a', 'Z')).toBeGreaterThan(0);
|
||||
expect(htmlTextComparator('apple', 'banana')).toBeLessThan(0);
|
||||
});
|
||||
@@ -1,74 +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 { isProbablyHTML, sanitizeHtml } from '@superset-ui/core';
|
||||
import { ValueGetterParams } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
|
||||
const stripHtmlToText = (html: string): string => {
|
||||
const doc = new DOMParser().parseFromString(sanitizeHtml(html), 'text/html');
|
||||
return (doc.body.textContent || '').trim();
|
||||
};
|
||||
|
||||
// Cache the comparator-ready form per raw string. Both the HTML-detection
|
||||
// step (`isProbablyHTML`, which itself invokes DOMParser for HTML-looking
|
||||
// values) and the extraction step (`stripHtmlToText`, also DOMParser) are
|
||||
// expensive; sort runs `O(n log n)` comparator calls against the same set
|
||||
// of cell values. Memoizing the combined detection + extraction means each
|
||||
// unique cell value pays the cost once per session. Module-level scope;
|
||||
// bounded by the count of unique string cell values seen.
|
||||
const comparableTextCache = new Map<string, string>();
|
||||
|
||||
const toComparableText = (raw: string): string => {
|
||||
const cached = comparableTextCache.get(raw);
|
||||
if (cached !== undefined) return cached;
|
||||
const normalized = isProbablyHTML(raw) ? stripHtmlToText(raw) : raw;
|
||||
comparableTextCache.set(raw, normalized);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the visible-text representation of an HTML cell value so AG Grid
|
||||
* filters and sort operate on what the user sees, not the underlying markup.
|
||||
* Pass-through for non-HTML values.
|
||||
*/
|
||||
const htmlTextFilterValueGetter = (params: ValueGetterParams) => {
|
||||
const raw = params.data?.[params.colDef.field as string];
|
||||
return typeof raw === 'string' ? toComparableText(raw) : raw;
|
||||
};
|
||||
|
||||
/**
|
||||
* Comparator that mirrors AG Grid's default string comparator (codepoint
|
||||
* order, nulls first), but extracts visible text from HTML values first
|
||||
* so HTML cells sort by their displayed label. Plain (non-HTML) values
|
||||
* pass through unchanged, preserving default ordering — e.g. 'Z' still
|
||||
* sorts before 'a' as it does under the default comparator.
|
||||
*/
|
||||
export const htmlTextComparator = (a: unknown, b: unknown): number => {
|
||||
const toText = (v: unknown) =>
|
||||
typeof v === 'string' ? toComparableText(v) : v;
|
||||
const aT = toText(a);
|
||||
const bT = toText(b);
|
||||
if (aT == null && bT == null) return 0;
|
||||
if (aT == null) return -1;
|
||||
if (bT == null) return 1;
|
||||
if (typeof aT === 'number' && typeof bT === 'number') return aT - bT;
|
||||
if (aT === bT) return 0;
|
||||
return aT < bT ? -1 : 1;
|
||||
};
|
||||
|
||||
export default htmlTextFilterValueGetter;
|
||||
@@ -32,9 +32,6 @@ import {
|
||||
} from '../types';
|
||||
import getCellClass from './getCellClass';
|
||||
import filterValueGetter from './filterValueGetter';
|
||||
import htmlTextFilterValueGetter, {
|
||||
htmlTextComparator,
|
||||
} from './htmlTextFilterValueGetter';
|
||||
import dateFilterComparator from './dateFilterComparator';
|
||||
import DateWithFormatter from './DateWithFormatter';
|
||||
import { getAggFunc } from './getAggFunc';
|
||||
@@ -320,24 +317,6 @@ export const useColDefs = ({
|
||||
...(isPercentMetric && {
|
||||
filterValueGetter,
|
||||
}),
|
||||
...(dataType === GenericDataType.String &&
|
||||
!serverPagination && {
|
||||
// HTML cells (e.g. anchor markup) are rendered by TextCellRenderer
|
||||
// via dangerouslySetInnerHTML; without these the filter and sort
|
||||
// operate on raw HTML so the URL inside the markup dictates order
|
||||
// and the "Contains" filter matches against the raw HTML string.
|
||||
//
|
||||
// Gated on !serverPagination: in server-pagination mode sort and
|
||||
// filter are both delegated to the backend (which sees raw HTML
|
||||
// in the database), so applying the visible-text getter only on
|
||||
// the client would create a mismatch where the typed filter
|
||||
// value is stripped client-side but the server query still
|
||||
// operates on the raw HTML. Server-paginated tables with HTML
|
||||
// columns are out of scope for this fix and would require
|
||||
// server-side handling.
|
||||
filterValueGetter: htmlTextFilterValueGetter,
|
||||
comparator: htmlTextComparator,
|
||||
}),
|
||||
...(dataType === GenericDataType.Temporal && {
|
||||
// Use dateFilterValueGetter so AG Grid correctly identifies null dates for blank filter
|
||||
filterValueGetter: dateFilterValueGetter,
|
||||
|
||||
@@ -1,99 +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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Regression coverage for memoize-one v6 adoption.
|
||||
*
|
||||
* memoize-one v6 changed the signature of the (optional) custom `isEqual`
|
||||
* callback from per-argument `(a, b) => bool` to arg-array
|
||||
* `(newArgs, lastArgs) => bool`. Of the four memoizeOne callsites in
|
||||
* `src/transformProps.ts` (`processComparisonDataRecords`,
|
||||
* `processDataRecords`, `processColumns`, `getBasicColorFormatter`), only
|
||||
* `processColumns` passes a custom comparator (`isEqualColumns`); its
|
||||
* signature already takes arg-arrays and is compatible with v6. The other
|
||||
* three rely on memoize-one's default referential-equality comparator, which
|
||||
* is unchanged between v5 and v6.
|
||||
*
|
||||
* These tests lock those assumptions in by observing the memoization
|
||||
* behavior through the public `transformProps` API: identical chart-props
|
||||
* input references should produce referentially-equal `data` and `columns`
|
||||
* arrays (cache hit), while inputs that differ on the sub-fields each
|
||||
* memoizer actually compares should produce fresh arrays (cache miss).
|
||||
*/
|
||||
import transformProps from '../src/transformProps';
|
||||
import testData from '../../plugin-chart-table/test/testData';
|
||||
|
||||
test('transformProps returns referentially-equal data/columns on identical input (cache hit)', () => {
|
||||
// processColumns and processDataRecords are both wrapped by memoizeOne at
|
||||
// module scope. Two consecutive calls with the same chartProps reference
|
||||
// should hit both caches and yield the same output references.
|
||||
const first = transformProps(testData.basic);
|
||||
const second = transformProps(testData.basic);
|
||||
|
||||
expect(second.columns).toBe(first.columns);
|
||||
expect(second.data).toBe(first.data);
|
||||
});
|
||||
|
||||
test('transformProps busts its memoization caches when sub-field inputs change (cache miss)', () => {
|
||||
const first = transformProps(testData.basic);
|
||||
|
||||
// `processColumns` is wrapped with a custom equality (`isEqualColumns`) that
|
||||
// compares specific chartProps sub-fields by identity — mutating only the
|
||||
// top-level props reference is NOT enough to bust it. Here we supply a fresh
|
||||
// `datasource.columnFormats` reference, which `isEqualColumns` compares with
|
||||
// `===`, forcing `processColumns` to recompute and return a new `columns`
|
||||
// array.
|
||||
//
|
||||
// `processDataRecords` uses memoize-one's default referential equality on
|
||||
// `(data, columns)`. We also hand it a fresh `queriesData[0].data` array, so
|
||||
// together with the recomputed `columns` reference it too cache-misses.
|
||||
const freshProps = {
|
||||
...testData.basic,
|
||||
datasource: {
|
||||
...testData.basic.datasource,
|
||||
columnFormats: {},
|
||||
},
|
||||
queriesData: [
|
||||
{
|
||||
...testData.basic.queriesData[0],
|
||||
data: [...(testData.basic.queriesData[0].data || [])],
|
||||
},
|
||||
],
|
||||
};
|
||||
const second = transformProps(freshProps);
|
||||
|
||||
expect(second.columns).not.toBe(first.columns);
|
||||
expect(second.data).not.toBe(first.data);
|
||||
});
|
||||
|
||||
test('transformProps memoizes the comparison-mode data pipeline on identical input', () => {
|
||||
// Exercises `processComparisonDataRecords` (the third of four memoizeOne
|
||||
// callsites in transformProps.ts) via the `comparison` fixture, which has
|
||||
// `time_compare` set and therefore flows through the comparison branch
|
||||
// where `passedData = comparisonData`.
|
||||
//
|
||||
// Note: we don't assert reference equality on `columns` here because the
|
||||
// comparison branch runs `comparisonColumns` through the non-memoized
|
||||
// `processComparisonColumns` helper, which returns a fresh array on each
|
||||
// call by design.
|
||||
const first = transformProps(testData.comparison);
|
||||
const second = transformProps(testData.comparison);
|
||||
|
||||
expect(second.data).toBe(first.data);
|
||||
});
|
||||
@@ -1,61 +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/plugin-chart-country-map`
|
||||
|
||||
> Modern country/region choropleth chart for Apache Superset. Replaces `legacy-plugin-chart-country-map`.
|
||||
|
||||
## Status
|
||||
|
||||
🚧 **Work in progress** — see `SIP_DRAFT.md` in this directory for the full design rationale and implementation phases. This plugin lives in the same PR as the build pipeline that produces its data; both are currently scaffolded and being progressively fleshed out.
|
||||
|
||||
## What it offers vs. the legacy plugin
|
||||
|
||||
| | Legacy | New |
|
||||
|---|---|---|
|
||||
| Backend endpoint | `explore_json` | `chart/data` (modern) |
|
||||
| Disputed-region handling | Hardcoded NE editorial | Configurable per-deployment + per-chart worldview (NE `_ukr` default) |
|
||||
| Subdivisions level | Country-only | Country (Admin 0) **and** Subdivisions (Admin 1) **and** Aggregated regions |
|
||||
| Data pipeline | Jupyter notebook | Reproducible script + YAML configs (see `scripts/`) |
|
||||
| Per-deployment customization | Fork required | `superset_config.COUNTRY_MAP` block + chart-level controls |
|
||||
| Composite maps (e.g. France-with-Overseas) | Hardcoded in notebook | Declarative in `composite_maps.yaml` |
|
||||
| Regional aggregations (NUTS-1 etc.) | Hardcoded | Declarative in `regional_aggregations.yaml` |
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
index.ts — package entry; exports CountryMapChartPlugin and types
|
||||
types.ts — TypeScript types for form data + transform props
|
||||
CountryMap.tsx — React component; renders the SVG chart
|
||||
plugin/
|
||||
index.ts — ChartPlugin class with metadata
|
||||
buildQuery.ts — modern chart/data query builder
|
||||
controlPanel.tsx— form controls (worldview, admin level, country, ...)
|
||||
transformProps.ts — form_data → renderer props
|
||||
scripts/ — build pipeline (NE shapefile → simplified GeoJSON outputs)
|
||||
SIP_DRAFT.md — design draft for the eventual SIP issue
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- `SIP_DRAFT.md` — design rationale, audit of legacy notebook, obsolescence findings, open questions
|
||||
- `scripts/README.md` — build pipeline operating principles
|
||||
- `scripts/config/*.yaml` — declarative transform configs (5 schemas for the 5 transform categories)
|
||||
- `scripts/procedural/README.md` — escape hatch for edge cases YAML can't express
|
||||
@@ -1,610 +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.
|
||||
-->
|
||||
|
||||
# SIP Draft: Modernize the Country Map plugin
|
||||
|
||||
> **Status:** Draft / scratch — this file is a working reference for the eventual SIP. It will be filed as a GitHub issue when the POC is mature, then deleted from the tree.
|
||||
|
||||
> **Author:** @rusackas (with @anthropic Claude Code assistance)
|
||||
|
||||
> **Target release:** TBD (likely first appears as opt-in in Superset N, becomes default in N+1, legacy removed in N+2)
|
||||
|
||||
---
|
||||
|
||||
## Motivation
|
||||
|
||||
The current country-map plugin (`legacy-plugin-chart-country-map`) accumulates pain across three axes that we keep trying to solve technically when the underlying problem is editorial:
|
||||
|
||||
1. **Disputed borders are a recurring flashpoint.** Crimea/Sevastopol, Kashmir, Western Sahara, Kosovo, Palestine, Cyprus, Aksai Chin — every few months a contributor opens a PR claiming the map is "wrong". Most recently #35613 tried to redraw Russia's borders and was rejected because the project doesn't have a stated policy beyond "follow upstream Natural Earth". We have no mechanism for users who *do* want a specific cartographic perspective.
|
||||
|
||||
2. **The Jupyter notebook is the source of truth and it's killing us.** The notebook ingests Natural Earth, applies hand-rolled fixes (rename pins, flying-island moves, geometry touchups), and emits the per-country `.geojson` files we ship. It is:
|
||||
- opaque to git diff (the `.ipynb` JSON dump is unreadable in PRs)
|
||||
- fragile under conflict (cell ordering, kernel state, output churn)
|
||||
- bloated (years of one-off touchups, hard to audit)
|
||||
- undiscoverable (most contributors don't know it exists)
|
||||
|
||||
3. **The plugin is "legacy" for real reasons.** It still uses the `explore_json` endpoint instead of the modern `chart/data` endpoint. That means: no async, no chunking, no semantic-layer integration, bypasses modern caching, and registers as a `LegacyChartPlugin` rather than a modern `ChartPlugin`. We've been carrying the "legacy" prefix forever; this is the right moment to fix it.
|
||||
|
||||
Beyond those three, two related desires keep coming up:
|
||||
|
||||
- **Per-country subdivisions (Admin 1)** — French departments, Italian regions, US states, Türkiye city map (#32497), and others have all been submitted as bespoke per-country files over time. They're conceptually identical and should share infrastructure.
|
||||
- **Per-deployment customization** — language overrides, name pins, region include/exclude. Currently impossible without forking.
|
||||
|
||||
## Goals
|
||||
|
||||
- New plugin (`plugin-chart-country-map`, no `legacy-` prefix) built against `chart/data` endpoint.
|
||||
- Configurable **worldview** (default + per-deployment override + per-chart override) using Natural Earth's pre-baked worldview shapefiles.
|
||||
- Support both **Admin 0 (countries)** and **Admin 1 (subdivisions)** in a single plugin — fold in the per-country submissions that have been accumulating.
|
||||
- Per-chart **region include/exclude** controls, with **fit-to-selection** projection auto-zoom.
|
||||
- **Flying islands** toggle (default on, with composite projections where available; off drops them entirely).
|
||||
- **Replace the Jupyter notebook** with a script-based, reproducible build pipeline using `mapshaper` CLI.
|
||||
- **Deprecate the legacy plugin** with an in-UI "switch to new chart" affordance plus a deprecation banner consistent with the project's existing pattern.
|
||||
- Apache neutrality preserved by being explicit about default editorial choices and making them one-line-overrideable.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- World map plugin (bubble/proportional symbol overlays). Out of scope for this SIP — separate concern, future fold-in.
|
||||
- Custom GeoJSON upload as a first-class control. Useful but separate feature.
|
||||
- Combining geometries at runtime ("show India and Pakistan as one merged blob"). Out of scope; users wanting this should upload custom GeoJSON.
|
||||
- Admin 2 (counties, communes, etc.). Could come later; not in initial scope.
|
||||
|
||||
## Proposed design
|
||||
|
||||
### Data source
|
||||
|
||||
- **Natural Earth Vector** (https://github.com/nvkelso/natural-earth-vector), pinned to a specific release tag. Same source we use today.
|
||||
- Use NE's pre-baked **worldview shapefiles**: `ne_10m_admin_0_countries_<XXX>.shp` for each supported worldview, plus the equivalent Admin 1 files where available.
|
||||
- Available worldviews as of NE 5.x: `arg, bdg, bra, chn, deu, egy, esp, fra, gbr, grc, idn, ind, isr, ita, jpn, kor, mar, nep, nld, pak, pol, prt, pse, rus, sau, swe, tur, twn, ukr, usa, vnm` (plus default and a few others). 31 worldviews × 2 admin levels = ~62 GeoJSON files shipped at default simplification.
|
||||
|
||||
### Default worldview
|
||||
|
||||
**Recommendation: ship NE `_ukr` (Ukraine) worldview as Superset's default.** Documented as an explicit editorial choice, overridable via `superset_config.py`.
|
||||
|
||||
Rationale: spike (below) shows UA worldview cleanly delivers Crimea-as-Ukrainian, plus several other commonly-expected positions (Kosovo separate from Serbia, Western Sahara separate from Morocco, Palestine recognized, Cyprus undivided, Kashmir aligned with India). Default ("ungrouped" NE editorial) is more equivocal on these, which is closer to the current shipped behavior but doesn't match the stated preference for Crimea-as-Ukrainian.
|
||||
|
||||
Apache neutrality preserved by:
|
||||
- Documenting the choice transparently in plugin README and Superset docs
|
||||
- One-line override in `superset_config.py` to switch to any other NE worldview
|
||||
- Per-chart override via control panel
|
||||
|
||||
### Build pipeline
|
||||
|
||||
Replace the notebook with `scripts/country-maps/`:
|
||||
|
||||
```
|
||||
scripts/country-maps/
|
||||
build.sh # one-shot reproducible
|
||||
README.md # how to regenerate, when, and why
|
||||
config/
|
||||
name_overrides.yaml # ISO code → display name pinning
|
||||
flying_islands.yaml # well-known multi-polygon parts to drop or inset
|
||||
output/ # gitignored; CI artifacts go here
|
||||
```
|
||||
|
||||
`build.sh` does:
|
||||
|
||||
1. Download pinned NE shapefiles (default + each shipped worldview) to a cache dir.
|
||||
2. For each (worldview × admin_level) combination, run `mapshaper`:
|
||||
- Filter / select features
|
||||
- Apply `name_overrides.yaml` renames
|
||||
- Apply `flying_islands.yaml` part filtering
|
||||
- Simplify with topology preservation (`-simplify percentage=5% keep-shapes`)
|
||||
- Output as `<worldview>_admin<level>.geo.json` to `superset-frontend/plugins/plugin-chart-country-map/src/data/`.
|
||||
3. Validate: schema check, ISO code coverage, no degenerate geometries.
|
||||
|
||||
CI runs this script in a workflow that opens a PR if outputs change. Maintainers review the cartographic diff in the PR (which is now legible because we're diffing GeoJSON, not a notebook).
|
||||
|
||||
### Plugin architecture
|
||||
|
||||
**Name:** `@superset-ui/plugin-chart-country-map` (no `legacy-` prefix).
|
||||
|
||||
**Endpoint:** modern `chart/data`, registered as a `ChartPlugin`.
|
||||
|
||||
**Controls:**
|
||||
|
||||
| Control | Type | Notes |
|
||||
|---------|------|-------|
|
||||
| `admin_level` | Select | `0 (countries)` or `1 (subdivisions)` |
|
||||
| `country` | Select | Required when `admin_level == 1`; lists countries with available subdivisions |
|
||||
| `worldview` | Select | Defaults from `superset_config.COUNTRY_MAP.default_worldview` |
|
||||
| `region_includes` | MultiSelect | Optional whitelist by ISO code |
|
||||
| `region_excludes` | MultiSelect | Optional blacklist by ISO code |
|
||||
| `show_flying_islands` | Boolean | Default true |
|
||||
| `name_language` | Select | NE's `NAME_<LANG>` field (en/fr/de/es/ar/zh/ja/ru/...) |
|
||||
| ... existing color/data controls | | (preserved from legacy plugin) |
|
||||
|
||||
**Render flow:**
|
||||
|
||||
```
|
||||
Load: GeoJSON for (worldview, admin_level, country?) — cached, immutable
|
||||
Render:
|
||||
features = data.features
|
||||
.filter(by region_includes / region_excludes)
|
||||
.filter(by show_flying_islands → drop tagged parts)
|
||||
.map(applying name_overrides from form_data)
|
||||
projection.fitSize(viewport, featureCollection(features)) // fit-to-selection
|
||||
render paths
|
||||
```
|
||||
|
||||
The heavy preprocessing (worldview, simplification, default islands, default name overrides) is baked at build time. Per-chart controls (include/exclude, fly islands, language, name overrides) operate client-side on the loaded GeoJSON. No server-side per-request GeoJSON regeneration.
|
||||
|
||||
### Configuration
|
||||
|
||||
```python
|
||||
# superset_config.py
|
||||
COUNTRY_MAP = {
|
||||
"default_worldview": "ukr", # NE worldview code
|
||||
"default_name_language": "en", # NAME_EN field
|
||||
"name_overrides": { # one-off touchups
|
||||
# "BIH": "Bosnia",
|
||||
},
|
||||
"region_excludes": [], # ISO_A3 codes excluded globally
|
||||
}
|
||||
```
|
||||
|
||||
Static config only — no env var. This is a cartographic editorial decision, not a per-request flag.
|
||||
|
||||
### Hosting / asset path
|
||||
|
||||
Build outputs ship into Superset's Flask static directory at `superset/static/assets/country-maps/`, served at `/static/assets/country-maps/<file>`. No webpack involvement needed — Flask serves them directly.
|
||||
|
||||
The build script (`scripts/build.py`) writes there directly. Files are committed to the repo so a fresh ephemeral env can render the chart immediately without running the build first. Trade-off: ~17 MB of generated files in the tree (offset by removing the legacy plugin's ~34 MB of committed GeoJSON, net -17 MB).
|
||||
|
||||
Future optimizations if maintenance burden grows: gitignore + postinstall hook, CDN-hosted assets, or server-side lazy generation per request.
|
||||
|
||||
### Deprecation of legacy plugin
|
||||
|
||||
Two-phase, modeled on existing deprecated-chart pattern:
|
||||
|
||||
- **Phase 1 (release N):** legacy plugin gets a deprecation banner in the chart UI ("This chart type is deprecated. Switch to the new Country Map.") plus an in-UI **"Switch to new Country Map"** button that:
|
||||
- Creates a new chart with `viz_type='country_map'` (the new one)
|
||||
- Copies form_data fields where they map cleanly (datasource, metric, color settings)
|
||||
- Sets `worldview` to the configured default
|
||||
- Optionally pre-selects same country
|
||||
- Leaves the original chart untouched (user explicitly saves over or discards)
|
||||
- **Phase 2 (release N+1, ideally a major):** legacy plugin removed from default install, banner becomes hard error, button no longer needed (no legacy charts left to migrate from).
|
||||
|
||||
No DB migrations required at any phase. Old `viz_type` continues to function during Phase 1; in Phase 2 it gracefully degrades to "this chart type is no longer supported, please switch to country_map".
|
||||
|
||||
## Spike findings: UA worldview vs Default
|
||||
|
||||
Ran `mapshaper` against pinned NE master snapshots of `ne_10m_admin_0_countries.shp` (Default) and `ne_10m_admin_0_countries_ukr.shp` (UA worldview). Source: https://github.com/nvkelso/natural-earth-vector.
|
||||
|
||||
### Feature counts
|
||||
|
||||
- Default: **258 features**
|
||||
- UA: **249 features**
|
||||
|
||||
UA worldview drops 9 features that Default acknowledges as standalone disputed entities, consolidating them into their parent claimant: `BJN` (Bajo Nuevo Bank), `CNM` (Cyprus No Man's Land), `CYN` (Northern Cyprus), `KAB` (Baikonur), `KAS` (Siachen), `KOS` (Kosovo — wait, *kept* in UA but with different geometry; needs re-check), `SER` (Serranilla Bank), `SOL` (Somaliland), `SPI` (Spratly Islands).
|
||||
|
||||
These are micro-territories that don't materially affect a country-level choropleth. Worth documenting.
|
||||
|
||||
### Countries with geometry differences
|
||||
|
||||
18 shared features have different geometries between Default and UA. The cartographically meaningful ones:
|
||||
|
||||
| ISO | Country | What changes (UA vs Default) |
|
||||
|-----|---------|------------------------------|
|
||||
| **RUS** | Russia | Loses 1 polygon part (Crimea); area_proxy 5835 → 5828 |
|
||||
| **UKR** | Ukraine | Gains Crimean peninsula; bbox lat 45.21°N → 44.38°N; area_proxy 129 → 144 |
|
||||
| **SRB** | Serbia | Smaller (Kosovo treated as separate country) |
|
||||
| **MAR** | Morocco | **Much** smaller (Western Sahara excluded); area_proxy 232 → 100 |
|
||||
| **CYP** | Cyprus | Shown undivided (no separate Northern Cyprus); 4 parts → 3 parts |
|
||||
| **ISR** | Israel | Smaller (Palestinian territories excluded); PSE feature recognized |
|
||||
| **CHN** | China | Loses Aksai Chin (disputed border with India) |
|
||||
| **IND** | India | Northern Kashmir included; bbox lat 35.5°N → 35.65°N |
|
||||
| **KOR** | South Korea | Minor — possibly Liancourt Rocks / East Sea boundary |
|
||||
| **COL, BRA, JOR, KAZ, LBN, GRL, SAH, SDN, SOM** | various | Same bbox, geometry slightly changed (probably small border refinements; need cartographic eyeball) |
|
||||
|
||||
### Conclusion
|
||||
|
||||
**UA worldview is a clean default.** It gives the user-requested Crimea-as-Ukrainian *and* aligns with broadly-expected positions on Kosovo, Western Sahara, Palestine, Cyprus, Kashmir, and Aksai Chin. It's a reasonable Superset editorial choice that we can defend on multiple cartographic axes (not just one).
|
||||
|
||||
The 9 dropped micro-territories are a non-issue for choropleth visualization.
|
||||
|
||||
## Notebook audit: existing touchups and how the new design covers them
|
||||
|
||||
Audit of `superset-frontend/plugins/legacy-plugin-chart-country-map/scripts/Country Map GeoJSON Generator.ipynb` (96 cells). Categorized below to confirm the new design has a home for each kind of work the notebook does.
|
||||
|
||||
Status legend:
|
||||
- ✅ **Covered** — handled cleanly by the new design as currently sketched
|
||||
- 🟡 **Needs config** — handled, but requires entries in YAML config files that we'll need to port
|
||||
- 🟠 **Needs new feature** — design needs an addition before this works
|
||||
- ⚪ **Can become obsolete** — current NE may have fixed the underlying problem; verify per case
|
||||
|
||||
### Category 1 — Data ingestion and scale blending ✅
|
||||
|
||||
Notebook downloads NE Admin 0 + Admin 1 at 10m and 50m, blends them (uses 50m for some large countries to cap file size). New `build.sh` does the same; mapshaper handles per-country `-simplify` more cleanly than the notebook's hand-tuned switch.
|
||||
|
||||
### Category 2 — Country list curation 🟡 → ✅
|
||||
|
||||
Cell 12 hand-curates ~190 countries with an alias dict (`korea`→`south korea`, etc.) and inline comments documenting why some entries are commented out (territories that NE rolls into a parent country). New design generates this list deterministically from NE's ISO_A3 codes; aliases handled by the `name_language` field. Auto-purge of single-subdivision countries (cell 89) becomes a build-script default.
|
||||
|
||||
### Category 3 — Flying islands repositioning 🟡 (config-driven)
|
||||
|
||||
The biggest category. Each `reposition()` call translates and scales a far-flung territory to fit within or near the mainland viewport. Maps to `flying_islands.yaml` entries (one per country/territory):
|
||||
|
||||
| Country | Territories repositioned | Cell |
|
||||
|---------|--------------------------|------|
|
||||
| **USA** | Hawaii, Alaska | 19 |
|
||||
| **Norway** | Svalbard | 34 |
|
||||
| **Portugal** | Azores, Madeira (with extra `simplify=0.015`) | 37 |
|
||||
| **Spain** | Las Palmas, Santa Cruz de Tenerife (Canary Islands) | 40 |
|
||||
| **France** | Guadeloupe, Martinique, Guyane française, La Réunion, Mayotte | 56 |
|
||||
|
||||
Each entry encodes `(territory_id, x_offset, y_offset, x_scale, y_scale, simplify?)`. Build script applies them.
|
||||
|
||||
D3 composite projections (e.g. `geoAlbersUsa`, `geoConicEqualAreaFrance`) handle some of these natively at *render* time without geometry mutation — worth offering as an alternative path per-country where available.
|
||||
|
||||
### Category 4 — Antimeridian fix 🟡 → ✅
|
||||
|
||||
Cell 44's `shift_geom` splits Russia's Chukchi Autonomous Okrug at the 180° meridian and translates the eastern part by +360° so it renders contiguously. **Mapshaper has `-clip` and `-affine` operators that do this cleanly.** Single use case currently (Russia/Chukchi); if more crop up, generalize.
|
||||
|
||||
### Category 5 — Bounds clipping 🟡 (config-driven)
|
||||
|
||||
Cells 69-71, 76: `apply_bounds(df, NW, SE)` filters out features whose geometries don't fit a bbox. Used for:
|
||||
|
||||
| Country | Bounding box | Purpose | Cell |
|
||||
|---------|--------------|---------|------|
|
||||
| **Netherlands** | NW=(-20, 60), SE=(20, 20) | Drop Caribbean territories (Bonaire, Sint Eustatius, Saba, ABC islands) | 71 |
|
||||
| **UK** | NW=(-10, 60), SE=(20, 20) | Drop British Overseas Territories | 76 |
|
||||
|
||||
These overlap conceptually with flying islands. Decision: same `flying_islands.yaml` config, with two action modes — `drop` (current bounds-clip behavior) or `reposition` (current reposition behavior). Tied to the per-chart "Show flying islands" toggle.
|
||||
|
||||
### Category 6 — Adding territories to a country 🟠 (needs new feature)
|
||||
|
||||
The notebook has cases where it *adds* features to a country that NE has as a separate Admin 0 entity:
|
||||
|
||||
| Country | Added territories | Cell |
|
||||
|---------|-------------------|------|
|
||||
| **China** | Taiwan (CN-71), Hong Kong (CN-91), Macau (CN-92) — with Chinese names | 21-22 |
|
||||
| **Finland** | Åland (FI-01) — with Finnish name | 25-26 |
|
||||
| **Ukraine** | Crimea (UA-43), Sevastopol (UA-40) — moved from Russia | 28 |
|
||||
|
||||
**The Ukraine case is now handled by worldview selection (NE `_ukr` does this for free).**
|
||||
|
||||
The China and Finland cases are *editorial decisions*, not worldview decisions — NE genuinely has Taiwan as separate Admin 0, Åland as separate Admin 0, etc. We need a `territory_assignments.yaml` config that says "for the China Admin 1 view, also include features X, Y, Z from these other Admin 0 records, with these renamed iso codes". This is a real new feature in the build pipeline. Not hard to implement.
|
||||
|
||||
### Category 7 — Reassigning territories ✅ (covered by worldview)
|
||||
|
||||
The Crimea/Sevastopol move from Russia to Ukraine (cell 28) is exactly what UA worldview gives us for free. Confirmed by spike. **No config or code needed for this case in the new design.** Other reassignments (if they emerge) would also be candidates for worldview switches first, `territory_assignments.yaml` second.
|
||||
|
||||
### Category 8 — External GeoJSON replacement 🟠 (needs new feature, or work to obsolete)
|
||||
|
||||
The notebook fully bypasses NE for three countries by downloading third-party GeoJSON files:
|
||||
|
||||
| Country | Source | Why | Cell |
|
||||
|---------|--------|-----|------|
|
||||
| **India** | `geohacker/india` (Kashmir + Ladakh state shapes) | NE's India subdivisions deemed inadequate for J&K/Ladakh | 30 |
|
||||
| **Latvia** | `eriks47/latvia` | NE's Latvia subdivisions deemed inadequate | 73 |
|
||||
| **Philippines** | `jdruii/phgeojson` | NE's Philippines subdivisions deemed inadequate | 78 |
|
||||
|
||||
This is the gnarliest category. Two paths:
|
||||
|
||||
1. **Vendor the third-party GeoJSON in-tree** under `scripts/country-maps/external/` and have the build script merge them with the NE outputs. Config entry per country specifies the source file + which NE features it replaces.
|
||||
2. **Verify that current NE 5.x has improved** for these countries — possibly some of these hacks are obsolete. If so, drop the override, accept what NE ships.
|
||||
|
||||
Action: per-country verification spike before the SIP locks in. If even one needs to stay, we ship path 1 as a first-class config layer (`external_overrides.yaml`).
|
||||
|
||||
### Category 9 — Name fixes (typos, encoding, native names) 🟡 (config-driven)
|
||||
|
||||
Three sub-cases:
|
||||
|
||||
- **Typo fixes**: France (Seien→Seine, Haute→Haut), iso_3166_2 corrections (FR-75→FR-75C, FR-GP→FR-971, etc.) — cell 55
|
||||
- **Native script / diacritics**: Vietnam (~12 city names with Vietnamese diacritics) — cell 86; China SAR Chinese names; Finland Åland Finnish name
|
||||
- **Administrative renames**: Philippines (Dinagat→Caraga, ARMM→BARMM) — cell 83
|
||||
|
||||
All map to entries in `name_overrides.yaml` keyed by ISO code. The native script cases might also be obsoleted by exposing NE's `NAME_<lang>` fields directly via the `name_language` selector — verify per case.
|
||||
|
||||
### Category 10 — Region aggregation (Admin 1 → administrative regions) 🟠 (needs new feature)
|
||||
|
||||
Notebook builds *intermediate* admin levels by dissolving Admin 1 into administrative regions:
|
||||
|
||||
| Country | Aggregation | Source |
|
||||
|---------|-------------|--------|
|
||||
| **Turkey** | NUTS-1 (12 statistical regions) — manually-coded city→region dict | Cell 48 |
|
||||
| **France** | Administrative regions (dissolved from departments) | Cell 58-59 |
|
||||
| **Italy** | Regions (dissolved from provinces) | Cell 66 |
|
||||
| **Philippines** | Regions (dissolved from provinces) | Cell 81-82 |
|
||||
|
||||
Currently each is its own special case. Generalize via a `regional_aggregations.yaml` config: per (country, region_set_name), specify the mapping (Admin 1 ISO → region code + region name). Build script dissolves accordingly. New plugin exposes this as a third "admin level" option (`Admin 0 / Admin 1 / Aggregated regions`) when one is defined for the selected country.
|
||||
|
||||
This is a real feature, not just config porting. Worth calling out in the SIP as in-scope-but-distinct.
|
||||
|
||||
### Category 11 — Composite multi-country maps 🟠 (needs new feature, or punt)
|
||||
|
||||
Cell 63 ("France with Overseas") assembles a single map combining features from multiple Admin 0 entities (France + French Polynesia + French Southern Lands + Wallis-Futuna + New Caledonia + Saint-Pierre), each repositioned + dissolved. This is unique to the notebook.
|
||||
|
||||
Three paths:
|
||||
|
||||
1. **First-class composite-map support**: define `composite_maps.yaml` with rules for which Admin 0 records contribute to a composite, plus per-territory reposition rules. Build script generates a single composite GeoJSON.
|
||||
2. **Punt to "France" + "France with Overseas" as two distinct map options**, where the second is hand-curated upstream of the build pipeline. Smaller scope but loses the elegance.
|
||||
3. **Drop France-with-Overseas entirely** in the new plugin and let users use the regular France map; document the loss in UPDATING.md.
|
||||
|
||||
I'd argue **path 1** is small enough to fit the SIP scope and the cleanest answer. Builds reuse the same flying-islands + territory-assignment primitives. Path 3 would lose a feature people use.
|
||||
|
||||
### Category 12 — Geometry simplification ✅
|
||||
|
||||
Cell 90: hand-tuned `simplify_factors` per country, plus a size-based default. Mapshaper does this better with `-simplify` per layer. Build script per-country override config if needed.
|
||||
|
||||
### Category 13 — Output formatting ✅
|
||||
|
||||
Cell 90/94: writes per-country GeoJSON files plus a TypeScript `countries.ts` index with display-name overrides. New plugin generates the index from a known list of (worldview × admin level × country) tuples; display-name overrides become entries in `name_overrides.yaml`.
|
||||
|
||||
Backward-compatibility column rename (`iso_3166_2` → `ISO`, `name` → `NAME_1`) at the legacy plugin's data layer is a thing the new plugin doesn't need to inherit (we control both producer and consumer).
|
||||
|
||||
### Category 14 — Quality filtering ✅
|
||||
|
||||
Cell 89: auto-purge countries with only one subdivision. Becomes a build-script default. Already noted under Category 2.
|
||||
|
||||
### Summary of what the new design needs to add
|
||||
|
||||
To be a strict superset of the notebook's capabilities, the new design needs these config files / pipeline features beyond what's already sketched:
|
||||
|
||||
1. **`name_overrides.yaml`** ✅ (already in plan) — covers categories 2, 9, 13
|
||||
2. **`flying_islands.yaml`** ✅ (already in plan, but extend with `drop|reposition` action modes) — covers categories 3, 5
|
||||
3. **`territory_assignments.yaml`** 🟠 (NEW) — for adding features from other Admin 0 records (China + SARs, Finland + Åland) — covers category 6
|
||||
4. **`regional_aggregations.yaml`** 🟠 (NEW) — for Admin 1 → administrative-region dissolves (Turkey NUTS-1, France/Italy/Philippines regions) — covers category 10
|
||||
5. **`composite_maps.yaml`** 🟠 (NEW) — for France-with-overseas-style multi-country composites — covers category 11
|
||||
6. **`external_overrides.yaml`** 🟠 (very likely NOT needed) — see obsolescence check below; likely obviated by worldview selection + regional_aggregations
|
||||
7. **Antimeridian handling** ✅ (mapshaper primitives, possibly already obsolete)
|
||||
|
||||
The four `🟠 NEW` items are the design surface this audit added beyond what we'd already discussed. None of them are large; each is a config-driven build-script transform.
|
||||
|
||||
## Obsolescence check: which notebook fixes are still needed against current NE 5.x?
|
||||
|
||||
Ran a per-fix check against `ne_10m_admin_1_states_provinces.shp` (current). Findings:
|
||||
|
||||
| Notebook fix | Status in NE 5.x | Action |
|
||||
|---|---|---|
|
||||
| **France typos** (Seine, Haut-Rhin) | NE still ships "Seien-et-Marne" and "Haute-Rhin" | **KEEP** → `name_overrides.yaml` |
|
||||
| **France ISO 3166-2 codes** (FR-75→75C, FR-GP→971, etc.) | NE still uses old codes (6 features affected) | **KEEP** → `name_overrides.yaml` (or new `iso_overrides.yaml`) |
|
||||
| **Vietnam diacritics** (~12 manual rewrites) | NE's `.name` field still has unaccented values, BUT `.name_vi` field is correct ("Đồng Tháp", "Sơn La", etc.) | **OBSOLETE** → use `name_language=vi` instead of manual rewrites |
|
||||
| **Philippines admin renames** (Dinagat→Caraga, ARMM→BARMM) | NE still has the old names (5+6 affected features) | **KEEP** → `name_overrides.yaml` |
|
||||
| **Crimea/Sevastopol** (move from RUS to UKR) | Handled cleanly by NE `_ukr` worldview | **OBSOLETE** → falls out of worldview selection |
|
||||
| **India Kashmir/Ladakh geometry** (third-party replacement) | NE has both as Union Territories with correct ISO codes (IN-JK, IN-LA); notebook only replaces geometry. UA worldview already adjusts northern boundary | **LIKELY OBSOLETE** → verify geometry match against current Indian boundary claims; if NE Default/_ind/_ukr is acceptable, drop the override |
|
||||
| **Russia Chukchi antimeridian fix** | NE's Chukchi already has x range `[-180, 180]` — geometry has been split into multiple polygons that wrap correctly | **LIKELY OBSOLETE** → verify with actual D3 rendering; modern projections handle this |
|
||||
| **China + SARs** (add Taiwan/HK/Macau to CN admin1) | NE keeps Taiwan (TWN), Hong Kong (HKG), Macau (MAC) as separate Admin 0 records — not in CN admin1 | **KEEP** → `territory_assignments.yaml` |
|
||||
| **Finland + Åland** (add Åland to FIN admin1) | NE FIN admin1 has 18 features, missing Åland; ALA also absent from admin1 dataset | **KEEP** → `territory_assignments.yaml` (pull Åland from admin0) |
|
||||
| **Latvia third-party replacement** | NE has 119 admin1 features (110 Novads municipalities + 9 cities) — modern, fine-grained | **OBSOLETE** → use NE directly. Notebook's third-party file probably matches an older 5-region model; if that's wanted, expose it via `regional_aggregations.yaml` (5 historical regions dissolved from 119 municipalities) |
|
||||
| **Philippines third-party replacement** | NE has 118 admin1 features (80 provinces + 32 highly-urbanized cities + 6 others) | **OBSOLETE** → use NE directly. If users want the 17-region view, expose via `regional_aggregations.yaml` |
|
||||
| **France/Italy/Turkey/PHL regional dissolves** | Pure aggregation — NE admin1 data is fine | **KEEP** → `regional_aggregations.yaml` |
|
||||
| **France-with-Overseas composite** | No upstream support; NE has the territories spread across 6 separate Admin 0 records | **KEEP** → `composite_maps.yaml` |
|
||||
| **Flying-island repositions** (USA, Norway, Portugal, Spain, France DOM) | Cartographic editorial choices — NE doesn't and shouldn't make these | **KEEP** → `flying_islands.yaml` |
|
||||
| **Single-subdivision country auto-purge** | n/a, build-script default | **KEEP** → build script default |
|
||||
| **TypeScript countries.ts generator** | n/a, output | **KEEP** → simpler version generated from build-script manifest |
|
||||
|
||||
### Headline wins from the obsolescence check
|
||||
|
||||
- **`external_overrides.yaml` is probably NOT needed.** All three third-party-GeoJSON cases (India, Latvia, Philippines) are obviated either by worldview selection (India) or by NE having matured fine-grained admin1 data (Latvia, Philippines). The "older / coarser" subdivisions some users want become use cases for `regional_aggregations.yaml` instead.
|
||||
- **Vietnam manual rewrites disappear** when we expose NE's `NAME_<lang>` fields via the `name_language` selector. Twelve config entries collapse to zero.
|
||||
- **Crimea/Sevastopol** confirmed obsolete via `_ukr` worldview (already validated in main spike).
|
||||
- **Russia Chukchi antimeridian fix is likely obsolete.** NE has split Chukchi into properly-bounded polygons. We need to verify the new plugin's D3 projection handles them correctly at render time, but the data is in good shape.
|
||||
- **Net design surface shrinks:** the 6 NEW config files I sketched in the audit collapse to **4 NEW** (`territory_assignments`, `regional_aggregations`, `composite_maps`, plus the existing `name_overrides`/`flying_islands`/`iso_overrides`). One less config file, one less ongoing maintenance burden, no third-party data dependencies in-tree.
|
||||
|
||||
### Procedural escape hatch for edge cases
|
||||
|
||||
Most touchups fit cleanly in declarative YAML (typos, ISO codes, repositions, dissolves, composites). A few don't — e.g., the France-with-Overseas notebook drops the *second sub-polygon* of the Windward Islands feature by array index to avoid visual conflict with Corsica. Forcing every such case into the YAML schema would bloat it.
|
||||
|
||||
**Solution: small `procedural/` directory of named, single-purpose Python scripts** as an escape hatch, run by the build orchestrator after the YAML transforms.
|
||||
|
||||
```
|
||||
scripts/country-maps/
|
||||
build.sh # orchestrator
|
||||
config/ # declarative — handles 95%
|
||||
name_overrides.yaml
|
||||
flying_islands.yaml
|
||||
territory_assignments.yaml
|
||||
regional_aggregations.yaml
|
||||
composite_maps.yaml
|
||||
procedural/ # escape hatch — handles the rare 5%
|
||||
README.md # when to use, when not
|
||||
01_fix_windward_islands_geometry.py
|
||||
# ... future numbered one-offs
|
||||
```
|
||||
|
||||
Each procedural script is:
|
||||
- Single-purpose, named after what it does
|
||||
- Has a header comment explaining *why* this couldn't be expressed in YAML
|
||||
- Takes a geo input path, mutates, writes — clear in/out interface
|
||||
- Numbered prefix for deterministic execution order
|
||||
- Easily reviewable in PR (one fix per file, no kernel state, no cell ordering)
|
||||
|
||||
Why this is much better than the notebook:
|
||||
- Each fix is a separate file → conflicts localize to one fix at a time
|
||||
- No kernel state, no output churn, pure functions on data
|
||||
- Naming forces documentation; reviewers can see what's added/removed at a glance
|
||||
- Bounded growth: a `procedural/` directory with 50 files is annoying enough to be a signal to push fixes back into YAML or upstream into NE. The notebook never had this signal — it just bloated.
|
||||
|
||||
### What still requires manual attention going forward
|
||||
|
||||
The fixes that survive (France typos/ISO codes, Philippines admin renames, China+SARs, Finland+Åland, regional aggregations, composite maps, flying islands) are all relatively **stable** — they don't change year over year. Once ported to YAML, the maintenance cost is roughly: "watch for new disputed-region PRs (handle via worldview switch) + occasional admin-name updates (one-line YAML edit)". Way better than the notebook treadmill.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Default worldview confirmation.** Recommendation is `ukr`. Acceptable to ship that wholesale, or do we want a more granular `default_overrides` overlay model (NE Default + selectively swap Crimea geometry from `_ukr`)? The latter is more code but more editorially neutral on the non-Crimea pieces. — **resolved: ship `ukr` wholesale**
|
||||
2. **External GeoJSON overrides (notebook category 8).** India, Latvia, Philippines currently replace NE entirely with third-party GeoJSON. — **resolved: obsolescence check shows none of the three need to stay.** Latvia/Philippines have fine-grained NE admin1 data; India's J&K is handled by worldview selection (verify per region under `_ukr`). **`external_overrides.yaml` is dropped from the design.**
|
||||
3. **Composite maps (notebook category 11).** France-with-Overseas combines features from 6 different Admin 0 records. Three options: build first-class `composite_maps.yaml` support, ship hand-curated alternatives, or drop the feature entirely. **Lean: first-class config support.**
|
||||
4. **Regional aggregations (notebook category 10).** Turkey NUTS-1, France/Italy/Philippines regions need a `regional_aggregations.yaml` and a third "admin level" UX option (`Admin 0 / Admin 1 / Aggregated regions`). Confirm scope.
|
||||
5. **Admin 1 country coverage.** NE Admin 1 covers ~all countries but quality varies. Decide which countries are first-class supported (probably a curated list initially, opening up as we validate).
|
||||
6. **Plugin scaffolding pattern.** Match modern plugin pattern (mirror `plugin-chart-pivot-table` or similar)? Or modify in-flight as we go.
|
||||
7. **Smoke-test fixtures.** Five test cases that exercise the design end-to-end:
|
||||
- World choropleth (Admin 0, default worldview, no filters)
|
||||
- US states (Admin 1, country=USA, flying islands ON via Albers composite — Hawaii/Alaska repositioned)
|
||||
- US states (Admin 1, country=USA, flying islands OFF — Hawaii/Alaska dropped, viewport fits to mainland)
|
||||
- French departments (Admin 1, country=FRA, with the France-with-Overseas composite)
|
||||
- Turkey aggregated regions (Admin 1 → NUTS-1, country=TUR)
|
||||
8. **TLC code.** New file `ne_10m_admin_0_countries_tlc.shp` — what worldview is this? Need to identify before deciding whether to ship it.
|
||||
9. **Verify which notebook fixes are obsolete in current NE.** For each notebook touchup (typos, encoding, geometry replacements), check whether NE 5.x has it correct. Reduces config-file size; smaller surface to maintain.
|
||||
|
||||
## Implementation plan (rough)
|
||||
|
||||
### Phase 1: Data pipeline + spike validation
|
||||
- [x] Spike: UA vs Default worldview diff
|
||||
- [x] Audit existing notebook touchups; categorize → keep / drop / port to YAML config (see "Notebook audit" section)
|
||||
- [x] Per-country obsolescence check against current NE 5.x (see "Obsolescence check" section)
|
||||
- [x] Per-country external-GeoJSON check: India/Latvia/Philippines — confirmed `external_overrides.yaml` NOT needed
|
||||
- [x] Design + draft all five config schemas: `name_overrides.yaml`, `flying_islands.yaml`, `territory_assignments.yaml`, `regional_aggregations.yaml`, `composite_maps.yaml` + `procedural/README.md` for the escape hatch
|
||||
- [x] Write `scripts/build.sh` + `scripts/build.py` (mapshaper-based) consuming the YAML configs
|
||||
- [x] Implement all 5 transforms: name_overrides, flying_islands, territory_assignments, regional_aggregations, composite_maps
|
||||
- [x] Per-country Admin 1 split (220 output files instead of one monolith)
|
||||
- [x] Pipeline produces correct counts on every transform vs. notebook expectations
|
||||
- [ ] Verify Russia Chukchi renders correctly with current NE data + D3 projection (to confirm antimeridian-fix obsolescence)
|
||||
- [ ] Verify India J&K geometry against current Indian boundary expectations under `_ukr` worldview
|
||||
- [x] Generate all NE-published worldviews at Admin 0 (33 worldviews: default + 32 country-specific editorials shipped from NE 5.1.2). Admin 1 remains shared (single shared file per country, worldview-agnostic) because NE doesn't publish per-worldview Admin 1 variants.
|
||||
- [x] CI workflow for regeneration (informational drift detection — cross-platform mapshaper output reproducibility is still WIP, so failures comment on the PR but don't block)
|
||||
|
||||
### Phase 2: Plugin scaffolding
|
||||
- [x] Scaffold `plugin-chart-country-map` directory matching modern plugin structure
|
||||
- [x] Register against `chart/data` endpoint (buildQuery + ChartPlugin class)
|
||||
- [x] transformProps derives `geoJsonUrl` from form_data using the build script's output naming
|
||||
- [x] D3 renderer ported to modern modules (d3-geo, d3-color, d3-array); fully typed, no @ts-nocheck; fit-to-selection projection
|
||||
- [x] Hosting path wired — outputs ship to `superset/static/assets/country-maps/`, served by Flask at `/static/assets/country-maps/`
|
||||
- [x] Click-to-zoom interaction (4x zoom on click, click-elsewhere to zoom out, 600ms transition)
|
||||
- [ ] Cross-filter integration via setDataMask
|
||||
- [ ] Composite projection support (geoAlbersUsa for USA, etc.) at render time
|
||||
|
||||
### Phase 3: Controls
|
||||
- [x] Worldview selector (manifest-driven from build pipeline)
|
||||
- [x] Admin level segmented control (0 / 1 / Aggregated)
|
||||
- [x] Country selector (visible when admin_level !== 0; manifest-driven)
|
||||
- [x] Region set selector (visible when admin_level === 'aggregated'; manifest-driven)
|
||||
- [x] Composite selector (overrides admin_level + country; manifest-driven)
|
||||
- [x] Region include/exclude multi-selects
|
||||
- [x] Flying islands toggle (default ON)
|
||||
- [x] Name language selector (20 NE languages)
|
||||
- [x] Fit-to-selection projection refit (renderer auto-fits to filtered features)
|
||||
- [x] Replace hardcoded choice tables with manifest-driven lookups
|
||||
|
||||
### Phase 4: Deprecation wiring
|
||||
- [x] Add new VizType.CountryMapV2 = 'country_map_v2'
|
||||
- [x] Register new plugin in MainPreset alongside legacy
|
||||
- [x] Add @superset-ui/plugin-chart-country-map as workspace dep
|
||||
- [x] Mark legacy plugin with `label: ChartLabel.Deprecated` + explanation
|
||||
- [x] Rename legacy plugin's display to "Country Map (Legacy)"
|
||||
- [ ] "Switch to new Country Map" button + form_data migration logic
|
||||
- [ ] Auto-close superseded duplicate PRs (#32497, etc.) on merge
|
||||
|
||||
### Phase 5: Polish + docs
|
||||
- [x] Build manifest output (NE SHA + worldviews + admin levels + sizes)
|
||||
- [x] Tests: transformProps (10 cases) + buildQuery (3 cases) + controlPanel (10 cases) + build.py transforms (18 cases) = 41 total
|
||||
- [x] UPDATING.md entry
|
||||
- [x] CI workflow that regenerates outputs (fails PR if drift)
|
||||
- [ ] Real example images / thumbnails for the chart type picker (need actual rendering capture)
|
||||
- [ ] Update Superset docs site
|
||||
- [x] Manifest also written to `src/data/manifest.json` for synchronous import in controlPanel
|
||||
|
||||
## Current PR state (snapshot as of latest commit)
|
||||
|
||||
**What's working end-to-end:**
|
||||
|
||||
```
|
||||
$ ./scripts/build.sh
|
||||
Country Map build — pinned to NE v5.1.2 (f1890d9f)
|
||||
Loaded 10 name override entries
|
||||
Loaded flying_islands rules for 7 countries
|
||||
Loaded territory_assignments rules for 2 countries
|
||||
Loaded regional_aggregations: 4 region-sets across 4 countries
|
||||
Loaded composite_maps: 1 composites
|
||||
|
||||
Building worldview=ukr admin_level=0
|
||||
… wrote ukr_admin0.geo.json (2,101,149 bytes, 249 features)
|
||||
Building worldview=ukr admin_level=1
|
||||
… name_overrides: applied 19 field updates across 10 entries
|
||||
… flying_islands: repositioned 12 features, dropped 5 (outside-bbox)
|
||||
… territory_assignments: added 4 features from sibling Admin 0 records
|
||||
… TUR/nuts_1: 81 subdivisions → 12 regions → regional_TUR_nuts_1_ukr.geo.json (23 KB)
|
||||
… FRA/regions: 101 subdivisions → 18 regions → regional_FRA_regions_ukr.geo.json (32 KB)
|
||||
… ITA/regions: 110 subdivisions → 20 regions → regional_ITA_regions_ukr.geo.json (32 KB)
|
||||
… PHL/regions: 118 subdivisions → 17 regions → regional_PHL_regions_ukr.geo.json (32 KB)
|
||||
… france_overseas: 108 features → composite_france_overseas_ukr.geo.json (322 KB)
|
||||
… wrote 214 per-country Admin 1 files (total 14.7 MB)
|
||||
Done.
|
||||
```
|
||||
|
||||
**Plugin scaffold compiles** (placeholder renderer, modern ChartPlugin, buildQuery against chart/data, transformProps deriving the right GeoJSON URL from form_data).
|
||||
|
||||
**Not yet wired:** real D3 rendering, full control panel, hosting path for the build outputs, legacy plugin deprecation. Each is a discrete next commit.
|
||||
|
||||
## Test plan (for the maintainer to validate before merge)
|
||||
|
||||
This section accumulates checks the maintainer should run themselves
|
||||
rather than trust the agent's "looks right". Items get added as the
|
||||
implementation progresses; check them off as verified.
|
||||
|
||||
### Build pipeline (data correctness)
|
||||
|
||||
- [ ] **Crimea/Sevastopol shown as Ukrainian** in `ukr_admin1_UKR.geo.json` — visually inspect the southern coastline; UKR feature should include the Crimean peninsula extending to ~44.4°N
|
||||
- [ ] **France typos fixed**: `ukr_admin1_FRA.geo.json` should contain "Seine-et-Marne" and "Haut-Rhin" (NOT "Seien-et-Marne" or "Haute-Rhin")
|
||||
- [ ] **France ISO codes updated**: search FRA features for `iso_3166_2: "FR-75C"` (NOT "FR-75"), `"FR-971"` (NOT "FR-GP"), etc.
|
||||
- [ ] **Philippines admin renames**: PHL features should use "Caraga Administrative Region" and "Bangsamoro Autonomous Region in Muslim Mindanao (BARMM)" — not the older names
|
||||
- [ ] **China + SARs in Admin 1**: `ukr_admin1_CHN.geo.json` should contain Taiwan (CN-71), Hong Kong (CN-91), Macau (CN-92) as features
|
||||
- [ ] **Finland + Åland**: `ukr_admin1_FIN.geo.json` should contain Åland with iso_3166_2 = "FI-01"
|
||||
- [ ] **Flying islands repositioned**: USA admin1 should show Hawaii and Alaska repositioned near the lower 48; France admin1 should show DROMs near mainland
|
||||
- [ ] **Bbox drops applied**: Netherlands and UK admin1 should NOT show Caribbean / overseas territories
|
||||
- [ ] **Regional aggregations**: open `regional_TUR_nuts_1_ukr.geo.json` — should have exactly 12 features named İstanbul, Batı Marmara, Ege, etc.
|
||||
- [ ] **France with overseas composite**: `composite_france_overseas_ukr.geo.json` should contain mainland + 5 DROMs + Polynésie française + Kerguelen + Wallis-et-Futuna + Nouvelle-Calédonie + Saint-Pierre-et-Miquelon + Saint-Martin + Saint-Barthélémy + zoomed Paris/petite couronne
|
||||
- [ ] **File sizes look sane**: world admin0 ~2 MB, per-country admin1 mostly < 1 MB, regional aggregations ~30 KB
|
||||
|
||||
### Visual / cartographic quality (in chart)
|
||||
|
||||
- [ ] World choropleth renders with no broken or missing countries
|
||||
- [ ] Russia Chukchi region renders as one connected piece (verifies antimeridian-fix obsolescence)
|
||||
- [ ] India J&K geometry under `_ukr` worldview matches what Indian-audience users expect — if not, may want to ship `_ind` for India-heavy deployments instead
|
||||
- [ ] Per-chart payload sizes feel responsive (no multi-MB downloads on country-level charts)
|
||||
- [ ] Tooltips show the right value for each region (verify ISO match between data + GeoJSON)
|
||||
- [ ] Cross-filters work bidirectionally (clicking a region filters dashboard)
|
||||
|
||||
### Controls UX
|
||||
|
||||
- [ ] Worldview selector shows all built worldviews
|
||||
- [ ] Picking a different worldview swaps the geometry without re-querying data
|
||||
- [ ] Admin level segmented control (0/1/Aggregated) shows/hides the country picker correctly
|
||||
- [ ] Region include/exclude actually filters rendered features client-side
|
||||
- [ ] Flying islands toggle drops territories when off; viewport refits
|
||||
- [ ] Name language selector switches displayed names (e.g. en → fr)
|
||||
- [ ] Composite picker exposes France-with-Overseas as a discrete option
|
||||
|
||||
### Backward compatibility
|
||||
|
||||
- [ ] Existing legacy-plugin-chart-country-map dashboards continue to render unchanged
|
||||
- [ ] Legacy plugin shows deprecation banner pointing at the new chart type
|
||||
- [ ] "Switch to new Country Map" button creates a new chart with form_data correctly mapped (datasource, metric, color settings preserved; new fields default sensibly)
|
||||
- [ ] No DB migrations needed (verify via `superset db upgrade` is a no-op)
|
||||
|
||||
### Per-deployment customization (config)
|
||||
|
||||
- [ ] `superset_config.COUNTRY_MAP.default_worldview = "default"` correctly switches the default to NE Default for that deployment
|
||||
- [ ] `name_overrides` config in superset_config can add a deployment-specific rename without touching the YAML files
|
||||
- [ ] `region_excludes` global config drops features from all charts
|
||||
|
||||
### Performance + ops
|
||||
|
||||
- [ ] Build script is reproducible: same NE SHA + same configs → identical outputs (byte-for-byte)
|
||||
- [ ] Build script runs in CI and produces the expected files on a clean checkout
|
||||
- [ ] Plugin lazy-loads correctly (chart-type registry doesn't pull D3 unless this chart type is actually used)
|
||||
- [ ] No regression in legacy chart load times during the deprecation overlap
|
||||
|
||||
## References
|
||||
|
||||
- Natural Earth worldviews: https://www.naturalearthdata.com/blog/admin-0-disputed-areas/
|
||||
- Natural Earth Vector repo: https://github.com/nvkelso/natural-earth-vector
|
||||
- Mapshaper: https://github.com/mbloch/mapshaper
|
||||
- Mapbox Boundaries (similar worldview model): https://www.mapbox.com/boundaries
|
||||
- Prior PRs that surfaced this pain: #35613 (Russia borders), #32497 (Türkiye city names), and others.
|
||||
@@ -1,55 +0,0 @@
|
||||
{
|
||||
"name": "@superset-ui/plugin-chart-country-map",
|
||||
"version": "0.20.3",
|
||||
"description": "Superset Chart - Configurable country/region choropleth map (replaces legacy-plugin-chart-country-map)",
|
||||
"sideEffects": false,
|
||||
"main": "lib/index.js",
|
||||
"module": "esm/index.js",
|
||||
"files": [
|
||||
"esm",
|
||||
"lib"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/apache/superset.git",
|
||||
"directory": "superset-frontend/plugins/plugin-chart-country-map"
|
||||
},
|
||||
"keywords": [
|
||||
"superset",
|
||||
"geo",
|
||||
"choropleth",
|
||||
"map"
|
||||
],
|
||||
"author": "Superset",
|
||||
"license": "Apache-2.0",
|
||||
"bugs": {
|
||||
"url": "https://github.com/apache/superset/issues"
|
||||
},
|
||||
"homepage": "https://github.com/apache/superset/tree/master/superset-frontend/plugins/plugin-chart-country-map#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-geo": "^3.1.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-selection": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3-array": "^3.2.1",
|
||||
"@types/d3-color": "^3.1.3",
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/d3-selection": "^3.0.11",
|
||||
"@types/jest": "^30.0.0",
|
||||
"jest": "^30.3.0"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
# Build cache (downloaded NE shapefiles)
|
||||
.cache/
|
||||
|
||||
# Stale local output dir from earlier iterations (outputs now ship to
|
||||
# superset/static/assets/country-maps/ at the repo root)
|
||||
output/
|
||||
@@ -1,82 +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.
|
||||
-->
|
||||
|
||||
# Country Map data pipeline
|
||||
|
||||
This directory contains the build pipeline that turns upstream Natural Earth data into the GeoJSON files consumed by `@superset-ui/plugin-chart-country-map`.
|
||||
|
||||
It replaces the legacy `scripts/Country Map GeoJSON Generator.ipynb` notebook. See `SIP_DRAFT.md` in the parent directory for the full design rationale.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
scripts/
|
||||
build.sh # one-shot reproducible build
|
||||
README.md # this file
|
||||
config/ # declarative YAML — handles ~95% of fixes
|
||||
name_overrides.yaml # typos, deprecated ISO codes, admin renames
|
||||
flying_islands.yaml # repositioning + bbox drops for far-flung territories
|
||||
territory_assignments.yaml # add features from sibling Admin 0 records
|
||||
regional_aggregations.yaml # dissolve Admin 1 into administrative regions
|
||||
composite_maps.yaml # multi-country composites (e.g. France-with-Overseas)
|
||||
procedural/ # escape hatch — handles the rare 5%
|
||||
README.md # when to use, when not
|
||||
NN_<descriptive_name>.py # one focused script per genuine edge case
|
||||
output/ # gitignored — build artifacts
|
||||
```
|
||||
|
||||
## Worldviews
|
||||
|
||||
Natural Earth publishes per-country editorial variants of its Admin 0
|
||||
(countries) layer: `ne_10m_admin_0_countries_<code>.shp`. Each variant
|
||||
encodes that country's official stance on disputed borders — e.g.
|
||||
`ne_10m_admin_0_countries_ukr.shp` shows Crimea as Ukrainian; `_chn`
|
||||
shows Taiwan as part of China; `_iso` uses neutral ISO 3166-1 boundaries.
|
||||
|
||||
`build.py` builds Admin 0 for every NE-published worldview listed in
|
||||
the `WORLDVIEWS_ADMIN_0` constant — outputs are named
|
||||
`<worldview>_admin0.geo.json`. The plugin's worldview control reads the
|
||||
list from `manifest.json` and shows whatever the build produced.
|
||||
|
||||
NE does **not** publish per-worldview Admin 1 variants — subdivisions
|
||||
within a country come from a single global file. We build Admin 1 once
|
||||
(under the `ukr` filename prefix for back-compat) and the frontend
|
||||
always points Admin 1, regional, and composite URLs at that shared
|
||||
output regardless of which worldview the user has selected. The
|
||||
worldview choice only changes the country-borders map (Admin 0).
|
||||
|
||||
## Operating principles
|
||||
|
||||
- **Default tool: declarative YAML.** Most touchups are renames, repositions, dissolves, or filters — all expressible in YAML. Diffs are small, conflicts localize cleanly to one entry, contributors can submit "fix typo X" as a one-line PR.
|
||||
- **Escape hatch: `procedural/` directory** of small, named, single-purpose Python scripts for the rare cases YAML can't express cleanly. Each script has a header comment explaining *why* it's not in YAML. See `procedural/README.md` for the bar.
|
||||
- **Build is reproducible from a pinned NE version.** `build.sh` records the NE git SHA it consumed; outputs are deterministic given inputs.
|
||||
- **CI regenerates on schema change** and opens a PR if outputs differ. Maintainers review the cartographic diff in legible GeoJSON, not opaque notebook JSON.
|
||||
|
||||
## Workflow for adding a fix
|
||||
|
||||
1. Identify the upstream NE issue (wrong name, missing territory, etc.).
|
||||
2. **Try YAML first.** Add the smallest possible entry to the appropriate config file with a `description` field explaining the fix.
|
||||
3. If YAML can't express it cleanly, add a numbered script in `procedural/` with a header comment explaining why YAML didn't fit.
|
||||
4. Run `build.sh` locally, verify the output GeoJSON looks right.
|
||||
5. Open PR. Reviewer sees the YAML diff (or new procedural script) plus the regenerated GeoJSON.
|
||||
|
||||
## See also
|
||||
|
||||
- `SIP_DRAFT.md` (parent dir) — design rationale, notebook audit, obsolescence check
|
||||
- `procedural/README.md` — when to use the escape hatch
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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.
|
||||
|
||||
# Country Map build pipeline.
|
||||
#
|
||||
# One-shot, reproducible: pinned upstream NE version, deterministic outputs.
|
||||
# Replaces the legacy Jupyter notebook. See README.md for details.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Sanity checks
|
||||
command -v python3 >/dev/null || { echo "python3 required" >&2; exit 1; }
|
||||
command -v npx >/dev/null || { echo "npx (Node.js) required for mapshaper" >&2; exit 1; }
|
||||
|
||||
python3 -c "import yaml" 2>/dev/null || {
|
||||
echo "PyYAML required: pip install pyyaml" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
exec python3 build.py "$@"
|
||||
@@ -1,207 +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.
|
||||
|
||||
# Multi-country composite maps that pull features from several Admin 0
|
||||
# records into a single GeoJSON output, repositioning each into insets.
|
||||
#
|
||||
# The canonical example is "France with Overseas" — mainland France plus
|
||||
# 5 DROM departments (already part of FRA Admin 0) plus territories from
|
||||
# 5 separate Admin 0 records (PYF, ATF, WLF, NCL, SPM) all repositioned
|
||||
# around the mainland into one composite map.
|
||||
#
|
||||
# Build script reads each composite definition and:
|
||||
# 1. Loads the base country at the requested admin level
|
||||
# 2. Applies base_repositions to specified features
|
||||
# 3. For each addition: pulls the feature from another Admin 0 record,
|
||||
# optionally drops sub-polygons by index, repositions, dissolves
|
||||
# 4. Outputs a single GeoJSON keyed by the composite's identifier
|
||||
# 5. Plugin exposes it in the country picker with `display_name`
|
||||
#
|
||||
# Schema:
|
||||
# composites:
|
||||
# <composite_id>:
|
||||
# description: human-readable
|
||||
# display_name: text shown in UI dropdown
|
||||
# admin_level: 0 | 1 # which level the composite represents
|
||||
# base:
|
||||
# adm0_a3: <ISO3>
|
||||
# base_repositions: # optional; applied to base features
|
||||
# - description: human-readable
|
||||
# match: { name: <feature name>, ... }
|
||||
# offset: [x, y]
|
||||
# scale: <number> # optional
|
||||
# drop_parts: [<int>, ...] # optional; drop sub-polygon indices
|
||||
# group: true # optional; if match yields multiple
|
||||
# # features, transform them as a single
|
||||
# # MultiPolygon then split back out
|
||||
# # (preserves per-feature attributes)
|
||||
# # Used for metropolitan-area zoom-ins
|
||||
# # like Paris + petite couronne
|
||||
# additions: # features pulled from other Admin 0 records
|
||||
# - description: human-readable
|
||||
# from:
|
||||
# adm0_a3: <ISO3 source>
|
||||
# match: { ... } # which feature(s) to pull
|
||||
# dissolve: true # optional; merge all matched features
|
||||
# drop_parts: [<int>, ...] # optional
|
||||
# reposition:
|
||||
# offset: [x, y]
|
||||
# scale: <number> # optional
|
||||
# set: # optional; override attributes on
|
||||
# name: <new name> # the added/dissolved feature
|
||||
# iso_3166_2: <code>
|
||||
# [...]
|
||||
|
||||
composites:
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# France with Overseas — mainland France + DROMs + sister Admin 0
|
||||
# territories under French sovereignty, all repositioned into a single
|
||||
# frame. Most complex composite; templates the schema for others.
|
||||
# -------------------------------------------------------------------
|
||||
france_overseas:
|
||||
description: |
|
||||
Mainland France plus all overseas territories (DROMs + COMs +
|
||||
Polynesia + Southern Lands + Wallis-Futuna + New Caledonia +
|
||||
Saint-Pierre-et-Miquelon) shown in one composite map.
|
||||
display_name: "France (with overseas)"
|
||||
admin_level: 1
|
||||
base:
|
||||
adm0_a3: FRA
|
||||
base_repositions:
|
||||
# The 5 overseas DROMs (départements et régions d'outre-mer) — already
|
||||
# part of FRA Admin 0 in NE. Repositioned aggressively for layout.
|
||||
- description: Reposition Guadeloupe near mainland
|
||||
match: { name: Guadeloupe }
|
||||
offset: [53.2, 29.0]
|
||||
scale: 1.5
|
||||
- description: Reposition Martinique near mainland
|
||||
match: { name: Martinique }
|
||||
offset: [52.8, 27.5]
|
||||
scale: 1.5
|
||||
- description: Reposition French Guiana (shrunk — it's vast)
|
||||
match: { name: "Guyane française" }
|
||||
offset: [45.0, 35.5]
|
||||
scale: 0.3
|
||||
- description: Reposition La Réunion
|
||||
match: { name: "La Réunion" }
|
||||
offset: [-58.2, 60.5]
|
||||
scale: 1.5
|
||||
- description: Reposition Mayotte
|
||||
match: { name: Mayotte }
|
||||
offset: [-50.5, 52.2]
|
||||
scale: 2.0
|
||||
|
||||
# Paris + petite couronne — metropolitan zoom-in.
|
||||
# The 4 inner Paris-region departments are tiny and overlap visually
|
||||
# at country scale. Group them into one MultiPolygon, translate up
|
||||
# and right, scale 3x to make them visible. Splits back into 4
|
||||
# individual features after transform (preserving attrs).
|
||||
- description: Zoom in on Paris and the petite couronne (Hauts-de-Seine, Seine-Saint-Denis, Val-de-Marne)
|
||||
match:
|
||||
name: { in: ["Paris", "Hauts-de-Seine", "Seine-Saint-Denis", "Val-de-Marne"] }
|
||||
group: true
|
||||
offset: [6.3, 2.3]
|
||||
scale: 3.0
|
||||
|
||||
additions:
|
||||
# French Polynesia — only the Windward Islands (Tahiti area), and
|
||||
# we drop the second sub-polygon (Rimatuu) to avoid visual conflict
|
||||
# with Corsica when laid out.
|
||||
- description: Add Tahiti (Windward Islands) from French Polynesia, drop Rimatuu sub-polygon
|
||||
from:
|
||||
adm0_a3: PYF
|
||||
match: { name: "Windward Islands" }
|
||||
drop_parts: [1]
|
||||
reposition:
|
||||
offset: [158.2, 57.3]
|
||||
scale: 2.0
|
||||
set:
|
||||
name: "Polynésie française"
|
||||
iso_3166_2: FR-PF
|
||||
|
||||
# French Southern and Antarctic Lands — Kerguelen Islands only.
|
||||
- description: Add Archipel des Kerguelen from French Southern Lands
|
||||
from:
|
||||
adm0_a3: ATF
|
||||
match: { name: "Archipel des Kerguelen" }
|
||||
reposition:
|
||||
offset: [-63.5, 88.5]
|
||||
scale: 0.9
|
||||
set:
|
||||
name: "Terres australes et antarctiques françaises"
|
||||
iso_3166_2: FR-TF
|
||||
|
||||
# Wallis and Futuna — dissolve Alo + Uvea into one shape.
|
||||
- description: Add Wallis and Futuna (dissolved)
|
||||
from:
|
||||
adm0_a3: WLF
|
||||
dissolve: true
|
||||
reposition:
|
||||
offset: [170, 52.5]
|
||||
scale: 4.0
|
||||
set:
|
||||
name: "Wallis et Futuna"
|
||||
iso_3166_2: FR-WF
|
||||
|
||||
# New Caledonia — dissolve all subdivisions.
|
||||
- description: Add New Caledonia (dissolved)
|
||||
from:
|
||||
adm0_a3: NCL
|
||||
dissolve: true
|
||||
reposition:
|
||||
offset: [-165.5, 60.4]
|
||||
scale: 0.4
|
||||
set:
|
||||
name: "Nouvelle-Calédonie"
|
||||
iso_3166_2: FR-NC
|
||||
|
||||
# Saint-Pierre and Miquelon — dissolved
|
||||
- description: Add Saint-Pierre-et-Miquelon (dissolved)
|
||||
from:
|
||||
adm0_a3: SPM
|
||||
dissolve: true
|
||||
reposition:
|
||||
offset: [48, 4]
|
||||
scale: 3.0
|
||||
set:
|
||||
name: "Saint-Pierre-et-Miquelon"
|
||||
iso_3166_2: FR-PM
|
||||
|
||||
# Saint Martin (French part) — small Caribbean island
|
||||
- description: Add Saint Martin (French part)
|
||||
from:
|
||||
adm0_a3: MAF
|
||||
match: { admin: "Saint Martin" }
|
||||
reposition:
|
||||
offset: [54.8, 30.3]
|
||||
scale: 5.0
|
||||
set:
|
||||
name: "Saint-Martin"
|
||||
iso_3166_2: FR-MF
|
||||
|
||||
# Saint Barthélémy — small Caribbean island, scaled up significantly
|
||||
- description: Add Saint Barthélémy (heavily scaled — it's tiny)
|
||||
from:
|
||||
adm0_a3: BLM
|
||||
match: { admin: "Saint Barthelemy" }
|
||||
reposition:
|
||||
offset: [54.5, 30]
|
||||
scale: 8.0
|
||||
set:
|
||||
name: "Saint-Barthélémy"
|
||||
iso_3166_2: FR-BL
|
||||
@@ -1,154 +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.
|
||||
|
||||
# Per-country handling of far-flung territories.
|
||||
#
|
||||
# Two operations per country, both build-time:
|
||||
# - repositions: territories moved into insets near the mainland
|
||||
# - drop_outside_bbox: territories outside this bbox dropped entirely
|
||||
#
|
||||
# At chart-render time, the "show flying islands" toggle controls which
|
||||
# repositioned/dropped territories are visible:
|
||||
# - ON (default): the chart's renderer applies the repositions defined
|
||||
# here, OR uses the composite_projection if specified (preferred when
|
||||
# available — see geoAlbersUsa for example).
|
||||
# - OFF: territories matched by EITHER `repositions` OR
|
||||
# `drop_outside_bbox` are filtered out of the rendered feature set.
|
||||
#
|
||||
# Schema:
|
||||
# countries:
|
||||
# <ISO3>:
|
||||
# composite_projection: <d3 projection name> # optional, takes
|
||||
# # precedence over repositions when the renderer supports it
|
||||
# repositions:
|
||||
# - description: human-readable why
|
||||
# match: { name: <feature name>, ... }
|
||||
# offset: [x, y] # required; degrees
|
||||
# scale: <number> # optional; default 1.0
|
||||
# simplify: <number> # optional; mapshaper -simplify factor
|
||||
# drop_outside_bbox:
|
||||
# description: human-readable why
|
||||
# nw: [lon, lat] # northwest corner
|
||||
# se: [lon, lat] # southeast corner
|
||||
#
|
||||
# Match semantics: same as name_overrides.yaml — all conditions AND'd.
|
||||
# `match.name: { in: [a, b, c] }` matches any of a, b, c.
|
||||
|
||||
countries:
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# USA — Hawaii, Alaska
|
||||
# D3's geoAlbersUsa handles these natively at render time. We still
|
||||
# ship build-time repositions as a fallback for renderers that don't
|
||||
# support composite projections.
|
||||
# -------------------------------------------------------------------
|
||||
USA:
|
||||
composite_projection: geoAlbersUsa
|
||||
repositions:
|
||||
- description: Bring Hawaii in alongside California
|
||||
match: { name: Hawaii }
|
||||
offset: [51, 5.5]
|
||||
- description: Shrink and reposition Alaska below the lower 48
|
||||
match: { name: Alaska }
|
||||
offset: [35, -34]
|
||||
scale: 0.35
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Norway — Svalbard
|
||||
# -------------------------------------------------------------------
|
||||
NOR:
|
||||
repositions:
|
||||
- description: Bring Svalbard in closer to mainland Norway
|
||||
match: { name: Svalbard }
|
||||
offset: [-12, -8]
|
||||
scale: 0.5
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Portugal — Atlantic islands
|
||||
# -------------------------------------------------------------------
|
||||
PRT:
|
||||
repositions:
|
||||
- description: Pull the Azores closer to mainland
|
||||
match: { name: Azores }
|
||||
offset: [11, 0]
|
||||
- description: Pull Madeira closer; small extra simplify because dense coastline
|
||||
match: { name: Madeira }
|
||||
offset: [6, 2]
|
||||
simplify: 0.015
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Spain — Canary Islands
|
||||
# -------------------------------------------------------------------
|
||||
ESP:
|
||||
repositions:
|
||||
- description: Bring Canary Islands closer to mainland Spain
|
||||
match:
|
||||
name: { in: ["Las Palmas", "Santa Cruz de Tenerife"] }
|
||||
offset: [3, 7]
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# France — Overseas DROMs (Départements et régions d'outre-mer)
|
||||
# For the full France-with-Overseas composite (incl. PYF, ATF, WLF,
|
||||
# NCL, SPM), see composite_maps.yaml.
|
||||
# -------------------------------------------------------------------
|
||||
FRA:
|
||||
composite_projection: geoConicEqualAreaFrance # if available in renderer
|
||||
repositions:
|
||||
- description: Reposition Guadeloupe near mainland France
|
||||
match: { name: Guadeloupe }
|
||||
offset: [57.4, 25.4]
|
||||
scale: 1.5
|
||||
- description: Reposition Martinique near mainland France
|
||||
match: { name: Martinique }
|
||||
offset: [58.4, 27.1]
|
||||
scale: 1.5
|
||||
- description: Reposition French Guiana (shrunk significantly — it's larger than mainland France)
|
||||
match: { name: "Guyane française" }
|
||||
offset: [52, 37.7]
|
||||
scale: 0.35
|
||||
- description: Reposition La Réunion
|
||||
match: { name: "La Réunion" }
|
||||
offset: [-55, 62.8]
|
||||
scale: 1.5
|
||||
- description: Reposition Mayotte
|
||||
match: { name: Mayotte }
|
||||
offset: [-43, 54.3]
|
||||
scale: 1.5
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Netherlands — drop Caribbean territories
|
||||
# The Caribbean Netherlands (Bonaire, Sint Eustatius, Saba) and the
|
||||
# constituent countries (Aruba, Curaçao, Sint Maarten) are far from
|
||||
# mainland NL. The notebook drops them rather than repositioning;
|
||||
# we preserve that editorial choice.
|
||||
# -------------------------------------------------------------------
|
||||
NLD:
|
||||
drop_outside_bbox:
|
||||
description: Drop Caribbean Netherlands & ABC islands; keep mainland + Frisian islands
|
||||
nw: [-20, 60]
|
||||
se: [20, 20]
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# United Kingdom — drop British Overseas Territories
|
||||
# Same pattern as NLD — drop (don't reposition) territories far from
|
||||
# the British Isles.
|
||||
# -------------------------------------------------------------------
|
||||
GBR:
|
||||
drop_outside_bbox:
|
||||
description: Drop British Overseas Territories; keep British Isles
|
||||
nw: [-10, 60]
|
||||
se: [20, 20]
|
||||
@@ -1,98 +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.
|
||||
|
||||
# Per-feature attribute corrections to Natural Earth data.
|
||||
#
|
||||
# Use when NE has a wrong value for a specific feature: typos, outdated
|
||||
# administrative names, deprecated ISO codes, etc.
|
||||
# For one-off geometry fixes, use procedural/ scripts instead.
|
||||
#
|
||||
# Schema:
|
||||
# overrides:
|
||||
# - description: Human-readable why this override exists (REQUIRED)
|
||||
# match:
|
||||
# adm0_a3: <ISO3 country code> # required: scope to one country
|
||||
# <field>: <value> # one or more match conditions
|
||||
# set:
|
||||
# <field>: <value> # one or more fields to set
|
||||
# [...]
|
||||
#
|
||||
# Match semantics: ALL conditions must match (logical AND). Apply to
|
||||
# both Admin 0 and Admin 1 features unless scope is restricted further.
|
||||
#
|
||||
# Tracking: each override should be revisited periodically against
|
||||
# upstream NE — many of these become obsolete when NE catches up.
|
||||
|
||||
overrides:
|
||||
# -------------------------------------------------------------------
|
||||
# France — typos in NE attribute table (NE 5.x still ships these)
|
||||
# -------------------------------------------------------------------
|
||||
- description: Fix typo "Seien-et-Marne" → "Seine-et-Marne"
|
||||
match: { adm0_a3: FRA, name: "Seien-et-Marne" }
|
||||
set: { name: "Seine-et-Marne" }
|
||||
|
||||
- description: Fix typo "Haute-Rhin" → "Haut-Rhin"
|
||||
match: { adm0_a3: FRA, name: "Haute-Rhin" }
|
||||
set: { name: "Haut-Rhin" }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# France — update ISO 3166-2 codes to current values
|
||||
# NE still uses pre-2016 region codes; map them to current standard.
|
||||
# -------------------------------------------------------------------
|
||||
- description: Paris uses ISO 3166-2 code FR-75C as of 2016 (NE has FR-75)
|
||||
match: { adm0_a3: FRA, iso_3166_2: "FR-75" }
|
||||
set: { iso_3166_2: "FR-75C" }
|
||||
|
||||
- description: Guadeloupe is FR-971 in current ISO (NE has FR-GP)
|
||||
match: { adm0_a3: FRA, iso_3166_2: "FR-GP" }
|
||||
set: { iso_3166_2: "FR-971" }
|
||||
|
||||
- description: Martinique is FR-972 in current ISO (NE has FR-MQ)
|
||||
match: { adm0_a3: FRA, iso_3166_2: "FR-MQ" }
|
||||
set: { iso_3166_2: "FR-972" }
|
||||
|
||||
- description: French Guiana is FR-973 in current ISO (NE has FR-GF)
|
||||
match: { adm0_a3: FRA, iso_3166_2: "FR-GF" }
|
||||
set: { iso_3166_2: "FR-973" }
|
||||
|
||||
- description: La Réunion is FR-974 in current ISO (NE has FR-RE)
|
||||
match: { adm0_a3: FRA, iso_3166_2: "FR-RE" }
|
||||
set: { iso_3166_2: "FR-974" }
|
||||
|
||||
- description: Mayotte is FR-976 in current ISO (NE has FR-YT)
|
||||
match: { adm0_a3: FRA, iso_3166_2: "FR-YT" }
|
||||
set: { iso_3166_2: "FR-976" }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Philippines — administrative renames
|
||||
# -------------------------------------------------------------------
|
||||
- description: Region XIII renamed to "Caraga" in 2010 (NE still says "Dinagat Islands")
|
||||
match: { adm0_a3: PHL, region: "Dinagat Islands (Region XIII)" }
|
||||
set: { region: "Caraga Administrative Region (Region XIII)" }
|
||||
|
||||
- description: ARMM reorganized as BARMM under the Bangsamoro Organic Law (2018-2019)
|
||||
match: { adm0_a3: PHL, region: "Autonomous Region in Muslim Mindanao (ARMM)" }
|
||||
set: { region: "Bangsamoro Autonomous Region in Muslim Mindanao (BARMM)" }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# NOT included here — handled by other mechanisms:
|
||||
# - Vietnam diacritics → use NE's NAME_VI field via name_language=vi
|
||||
# - Crimea/Sevastopol → handled by NE _ukr worldview selection
|
||||
# - China + SARs → see territory_assignments.yaml
|
||||
# - Finland + Åland → see territory_assignments.yaml
|
||||
# - France-with-Overseas → see composite_maps.yaml
|
||||
# -------------------------------------------------------------------
|
||||
@@ -1,111 +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.
|
||||
|
||||
# Dissolve Admin 1 features into coarser administrative regions.
|
||||
#
|
||||
# Some countries have a meaningful intermediate level between Admin 0
|
||||
# (country) and Admin 1 (provinces/states/departments). Examples:
|
||||
# - Turkey: NUTS-1 statistical regions (12 regions from 81 provinces)
|
||||
# - France: 18 administrative regions dissolved from 101 departments
|
||||
# - Italy: 20 regions dissolved from 110 provinces
|
||||
# - Philippines: 17 regions dissolved from 118 provinces+cities
|
||||
#
|
||||
# For each defined region set, the build script:
|
||||
# 1. Loads the country's Admin 1 features
|
||||
# 2. Dissolves features by the mapping below
|
||||
# 3. Outputs a new GeoJSON keyed by `<country>_<set_name>`
|
||||
# 4. Plugin exposes it as a third "admin level" option in the UI:
|
||||
# "Admin 0 (countries) / Admin 1 (subdivisions) / Aggregated regions"
|
||||
#
|
||||
# Schema:
|
||||
# countries:
|
||||
# <ISO3>:
|
||||
# region_sets:
|
||||
# <set_name>: # arbitrary identifier
|
||||
# description: human-readable
|
||||
# display_name: text shown in UI dropdown
|
||||
# grouping_field: <field> # field on Admin 1 features used to group
|
||||
# # OR
|
||||
# explicit_mapping: # explicit ISO → region_code dict
|
||||
# <region_code>:
|
||||
# name: <display name>
|
||||
# members: [<iso_3166_2>, ...]
|
||||
|
||||
countries:
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Turkey — NUTS-1 statistical regions
|
||||
# Hand-coded mapping of 81 cities → 12 regions per Eurostat NUTS-1
|
||||
# classification adapted for Türkiye.
|
||||
# -------------------------------------------------------------------
|
||||
TUR:
|
||||
region_sets:
|
||||
nuts_1:
|
||||
description: Eurostat NUTS-1 statistical regions for Türkiye
|
||||
display_name: "Türkiye (NUTS-1 regions)"
|
||||
explicit_mapping:
|
||||
TR1: { name: "İstanbul", members: [TR-34] }
|
||||
TR2: { name: "Batı Marmara", members: [TR-59, TR-22, TR-39, TR-10, TR-17] }
|
||||
TR3: { name: "Ege", members: [TR-35, TR-09, TR-20, TR-48, TR-45, TR-03, TR-43, TR-64] }
|
||||
TR4: { name: "Doğu Marmara", members: [TR-16, TR-26, TR-11, TR-41, TR-54, TR-81, TR-14, TR-77] }
|
||||
TR5: { name: "Batı Anadolu", members: [TR-06, TR-42, TR-70] }
|
||||
TR6: { name: "Akdeniz", members: [TR-07, TR-32, TR-15, TR-01, TR-33, TR-31, TR-46, TR-80] }
|
||||
TR7: { name: "Orta Anadolu", members: [TR-71, TR-68, TR-51, TR-50, TR-40, TR-38, TR-58, TR-66] }
|
||||
TR8: { name: "Batı Karadeniz", members: [TR-67, TR-78, TR-74, TR-37, TR-18, TR-57, TR-55, TR-60, TR-19, TR-05] }
|
||||
TR9: { name: "Doğu Karadeniz", members: [TR-61, TR-52, TR-28, TR-53, TR-08, TR-29] }
|
||||
TRA: { name: "Kuzeydoğu Anadolu", members: [TR-25, TR-24, TR-69, TR-04, TR-36, TR-76, TR-75] }
|
||||
TRB: { name: "Ortadoğu Anadolu", members: [TR-44, TR-23, TR-12, TR-62, TR-65, TR-49, TR-13, TR-30] }
|
||||
TRC: { name: "Güneydoğu Anadolu", members: [TR-27, TR-02, TR-79, TR-63, TR-21, TR-47, TR-72, TR-73, TR-56] }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# France — 18 administrative regions (since 2016 reform)
|
||||
# Use NE's `region_cod` field to group departments. After name fixes
|
||||
# in name_overrides.yaml, the codes should align with the 2016 reform.
|
||||
# -------------------------------------------------------------------
|
||||
FRA:
|
||||
region_sets:
|
||||
regions:
|
||||
description: French administrative regions (post-2016 reform)
|
||||
display_name: "France (regions)"
|
||||
grouping_field: region_cod
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Italy — 20 regions
|
||||
# -------------------------------------------------------------------
|
||||
ITA:
|
||||
region_sets:
|
||||
regions:
|
||||
description: Italian administrative regions
|
||||
display_name: "Italy (regions)"
|
||||
grouping_field: region_cod
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Philippines — 17 regions (after Caraga / BARMM renames)
|
||||
# -------------------------------------------------------------------
|
||||
PHL:
|
||||
region_sets:
|
||||
regions:
|
||||
description: Philippine administrative regions
|
||||
display_name: "Philippines (regions)"
|
||||
grouping_field: region
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Future candidates (not yet enabled — verify NE field availability):
|
||||
# - DEU: Bundesländer aggregation if NE provides Kreise as Admin 1
|
||||
# - GBR: NUTS-1 regions (England + Wales + Scotland + NI subdivisions)
|
||||
# - USA: BEA regions, Census divisions
|
||||
# -------------------------------------------------------------------
|
||||
@@ -1,98 +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.
|
||||
|
||||
# Pull features from sibling Admin 0 records and add them to a country's
|
||||
# Admin 1 view, optionally with a renamed iso_3166_2 code and translated
|
||||
# names.
|
||||
#
|
||||
# Use when NE classifies a territory as a separate Admin 0 record but,
|
||||
# for the purposes of a particular country's Admin 1 chart, it should
|
||||
# appear inside that country. Common cases:
|
||||
# - China + Taiwan/HK/Macau (NE has each as separate Admin 0)
|
||||
# - Finland + Åland (NE has Åland separate; missing from FIN admin 1)
|
||||
#
|
||||
# This is NOT a tool for "moving" disputed territories between countries
|
||||
# — for that, use NE worldview selection (e.g., the _ukr worldview moves
|
||||
# Crimea from RUS to UKR for free, no config needed).
|
||||
#
|
||||
# Schema:
|
||||
# countries:
|
||||
# <ISO3 destination country>:
|
||||
# additions:
|
||||
# - description: human-readable why
|
||||
# from:
|
||||
# adm0_a3: <ISO3 source country>
|
||||
# match:
|
||||
# name_en: <feature name> # or other matchers
|
||||
# set:
|
||||
# iso_3166_2: <new code> # set when added
|
||||
# name: <override display name> # optional
|
||||
# name_<lang>: <translation> # optional, per language
|
||||
# [...]
|
||||
#
|
||||
# Match semantics: same as other configs.
|
||||
|
||||
countries:
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# China — add Special Administrative Regions
|
||||
# NE keeps Taiwan (TWN), Hong Kong (HKG), and Macau (MAC) as separate
|
||||
# Admin 0 records. For the China subdivision view we re-attach them
|
||||
# using the official ISO 3166-2 codes (CN-71/91/92), with Chinese
|
||||
# names from the official translations.
|
||||
# -------------------------------------------------------------------
|
||||
CHN:
|
||||
additions:
|
||||
- description: Add Taiwan as China subdivision CN-71
|
||||
from:
|
||||
adm0_a3: TWN
|
||||
match: { name_en: Taiwan }
|
||||
set:
|
||||
iso_3166_2: CN-71
|
||||
name_zh: 中国台湾
|
||||
|
||||
- description: Add Hong Kong SAR as CN-91
|
||||
from:
|
||||
adm0_a3: HKG
|
||||
match: { name_en: Hong Kong }
|
||||
set:
|
||||
iso_3166_2: CN-91
|
||||
name_zh: 香港特别行政区
|
||||
|
||||
- description: Add Macau SAR as CN-92
|
||||
from:
|
||||
adm0_a3: MAC
|
||||
match: { name_en: Macau }
|
||||
set:
|
||||
iso_3166_2: CN-92
|
||||
name_zh: 澳门特别行政区
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Finland — add Åland
|
||||
# NE has Åland as a separate Admin 0 record (note: NE uses ALD, not
|
||||
# the ISO 3166-1 ALA) and it is missing from the FIN admin1 dataset.
|
||||
# Re-attach it as FI-01 with the Finnish name "Ahvenanmaan maakunta".
|
||||
# -------------------------------------------------------------------
|
||||
FIN:
|
||||
additions:
|
||||
- description: Add Åland as Finland subdivision FI-01
|
||||
from:
|
||||
adm0_a3: ALD # NE-specific code; ISO equivalent is ALA
|
||||
match: { name_en: Åland }
|
||||
set:
|
||||
iso_3166_2: FI-01
|
||||
name_fi: Ahvenanmaan maakunta
|
||||
@@ -1,81 +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.
|
||||
-->
|
||||
|
||||
# Procedural escape hatch
|
||||
|
||||
Small, named, single-purpose Python scripts for the rare cases where declarative YAML in `../config/` can't cleanly express a fix.
|
||||
|
||||
## When to put a script here
|
||||
|
||||
Use this directory when **all** of the following are true:
|
||||
|
||||
- You've tried to express the fix in YAML and the resulting schema is awkward, ambiguous, or requires a one-off type to be added
|
||||
- The fix is small (typically <50 lines of code, single conceptual operation)
|
||||
- The fix is tied to a *specific feature* in the data (not a generalizable transform)
|
||||
|
||||
## When NOT to put a script here
|
||||
|
||||
If any of the following apply, the fix belongs in `../config/` instead:
|
||||
|
||||
- It's a typo, rename, or attribute correction → `name_overrides.yaml`
|
||||
- It's a reposition or bbox drop of a known territory → `flying_islands.yaml`
|
||||
- It's adding a feature from another country → `territory_assignments.yaml`
|
||||
- It's dissolving Admin 1 into a coarser admin level → `regional_aggregations.yaml`
|
||||
- It's a multi-country composite → `composite_maps.yaml`
|
||||
|
||||
If the same kind of operation surfaces here twice, that's a signal to extend a YAML schema rather than ship a third script.
|
||||
|
||||
## Script conventions
|
||||
|
||||
- **Filename:** `NN_<descriptive_snake_case>.py`. The numeric prefix sets execution order; the name documents intent.
|
||||
- **Header comment:** required. Must explain *what* the script does AND *why* this couldn't be expressed in YAML. If the "why" is weak, push it back into YAML.
|
||||
- **Interface:** each script defines `def apply(geo: dict) -> dict` taking a parsed GeoJSON FeatureCollection and returning the modified one. The build orchestrator handles I/O.
|
||||
- **No side effects** other than the returned data — no network calls, no file writes, no `print` other than logging via `sys.stderr`.
|
||||
- **Pure function over GeoJSON.** Don't import shapely/geopandas unless the operation truly needs polygon math; many fixes are just attribute mutations.
|
||||
|
||||
## Skeleton
|
||||
|
||||
```python
|
||||
"""
|
||||
NN_descriptive_name.py
|
||||
======================
|
||||
|
||||
WHAT: One-sentence summary of what this script does to the data.
|
||||
|
||||
WHY: One-paragraph explanation of why this couldn't be expressed in
|
||||
../config/<some_yaml>.yaml. If you find yourself writing
|
||||
"because I didn't want to add a field to the schema", push the
|
||||
fix into the YAML schema instead.
|
||||
|
||||
UPSTREAM TRACKING: link to NE issue / community discussion / blog post
|
||||
explaining the underlying source of the problem, so future
|
||||
maintainers can re-evaluate when upstream catches up.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def apply(geo: dict) -> dict:
|
||||
# ... mutate features ...
|
||||
return geo
|
||||
```
|
||||
|
||||
## Currently empty
|
||||
|
||||
There are no procedural scripts yet. The audit suggested the France-with-Overseas Windward Islands sub-polygon drop *might* warrant one, but `composite_maps.yaml` already has a `drop_parts` field that covers it. We'll add scripts here only if/when a genuine edge case proves YAML can't express it.
|
||||
@@ -1,294 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# 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.
|
||||
|
||||
# mypy: ignore-errors
|
||||
"""
|
||||
Unit tests for the Country Map build pipeline transforms.
|
||||
|
||||
Run with: python3 -m pytest test_build.py
|
||||
or: python3 test_build.py (uses the bundled `unittest` runner)
|
||||
|
||||
Tests focus on the pure-Python transforms in build.py — the geometry
|
||||
helpers and the YAML-config application functions. The mapshaper
|
||||
subprocess calls and shapefile downloads are not exercised here
|
||||
(they are integration concerns covered by the regen CI workflow).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
# Import the module under test from this directory.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import build # noqa: E402 (intentional after sys.path manipulation)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# _matches
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMatches(unittest.TestCase):
|
||||
def test_scalar_equality(self):
|
||||
props = {"adm0_a3": "FRA", "name": "Paris"}
|
||||
assert build._matches(props, {"adm0_a3": "FRA"})
|
||||
assert not build._matches(props, {"adm0_a3": "GBR"})
|
||||
|
||||
def test_multiple_conditions_anded(self):
|
||||
props = {"adm0_a3": "FRA", "name": "Paris"}
|
||||
assert build._matches(props, {"adm0_a3": "FRA", "name": "Paris"})
|
||||
assert not build._matches(props, {"adm0_a3": "FRA", "name": "Lyon"})
|
||||
|
||||
def test_in_list_membership(self):
|
||||
props = {"name": "Hawaii"}
|
||||
assert build._matches(props, {"name": {"in": ["Hawaii", "Alaska"]}})
|
||||
assert not build._matches(props, {"name": {"in": ["Texas", "Alaska"]}})
|
||||
|
||||
def test_missing_property(self):
|
||||
props = {"adm0_a3": "FRA"}
|
||||
assert not build._matches(props, {"name": "Paris"})
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Geometry helpers
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_polygon(points):
|
||||
"""Helper: build a Polygon GeoJSON dict from a single ring of [x, y]."""
|
||||
return {"type": "Polygon", "coordinates": [points]}
|
||||
|
||||
|
||||
def make_multipolygon(polygons):
|
||||
"""Helper: build a MultiPolygon GeoJSON dict from a list of single rings."""
|
||||
return {"type": "MultiPolygon", "coordinates": [[ring] for ring in polygons]}
|
||||
|
||||
|
||||
class TestBboxCenter(unittest.TestCase):
|
||||
def test_unit_square(self):
|
||||
geom = make_polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])
|
||||
cx, cy = build._bbox_center(geom)
|
||||
assert (cx, cy) == (0.5, 0.5)
|
||||
|
||||
def test_offset_square(self):
|
||||
geom = make_polygon([[10, 20], [12, 20], [12, 22], [10, 22], [10, 20]])
|
||||
cx, cy = build._bbox_center(geom)
|
||||
assert (cx, cy) == (11, 21)
|
||||
|
||||
|
||||
class TestTranslateAndScale(unittest.TestCase):
|
||||
def test_pure_translate(self):
|
||||
geom = make_polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])
|
||||
build._translate_and_scale(geom, offset=[10, 20], scale=1.0)
|
||||
# Each point should shift by (10, 20)
|
||||
assert geom["coordinates"][0][0] == [10, 20]
|
||||
assert geom["coordinates"][0][2] == [11, 21]
|
||||
|
||||
def test_pure_scale_around_centroid(self):
|
||||
# Square centered on origin scaled 2x → corners move outward
|
||||
geom = make_polygon([[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]])
|
||||
build._translate_and_scale(geom, offset=[0, 0], scale=2.0)
|
||||
# Bbox center stays at origin; corners now at ±2
|
||||
assert geom["coordinates"][0][0] == [-2, -2]
|
||||
assert geom["coordinates"][0][2] == [2, 2]
|
||||
|
||||
def test_translate_then_scale_combined(self):
|
||||
geom = make_polygon([[0, 0], [2, 0], [2, 2], [0, 2], [0, 0]])
|
||||
build._translate_and_scale(geom, offset=[10, 0], scale=0.5)
|
||||
# Centroid was (1, 1); each corner first scaled around centroid by
|
||||
# 0.5 → corners become (0.5, 0.5)..(1.5, 1.5); then translated +10x
|
||||
assert geom["coordinates"][0][0] == [10.5, 0.5]
|
||||
assert geom["coordinates"][0][2] == [11.5, 1.5]
|
||||
|
||||
def test_multipolygon_handled(self):
|
||||
geom = make_multipolygon(
|
||||
[
|
||||
[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]],
|
||||
[[5, 5], [6, 5], [6, 6], [5, 6], [5, 5]],
|
||||
]
|
||||
)
|
||||
build._translate_and_scale(geom, offset=[100, 200], scale=1.0)
|
||||
assert geom["coordinates"][0][0][0] == [100, 200]
|
||||
assert geom["coordinates"][1][0][0] == [105, 205]
|
||||
|
||||
|
||||
class TestTranslateAndScaleWithPivot(unittest.TestCase):
|
||||
"""The `group: true` case in composite_maps uses a shared pivot
|
||||
so multiple features transform as one body."""
|
||||
|
||||
def test_features_with_shared_pivot_preserve_relative_positions(self):
|
||||
# Two unit squares, pivot at the midpoint between them
|
||||
a = make_polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])
|
||||
b = make_polygon([[3, 0], [4, 0], [4, 1], [3, 1], [3, 0]])
|
||||
# Their combined bbox center is (2, 0.5)
|
||||
pivot = (2, 0.5)
|
||||
# Scale 2x around shared pivot — they move APART
|
||||
build._translate_and_scale_with_pivot(a, [0, 0], 2.0, pivot)
|
||||
build._translate_and_scale_with_pivot(b, [0, 0], 2.0, pivot)
|
||||
# `a`'s right edge was at x=1, distance 1 from pivot → new x=0
|
||||
# `b`'s left edge was at x=3, distance 1 from pivot → new x=4
|
||||
# Gap between them grows from 2 to 4 (preserved relative position).
|
||||
assert a["coordinates"][0][1] == [
|
||||
0,
|
||||
-0.5,
|
||||
] # was [1,0]: scaled -1 from pivot x=2
|
||||
assert b["coordinates"][0][0] == [4, -0.5] # was [3,0]: scaled +1 from pivot
|
||||
|
||||
|
||||
class TestDropParts(unittest.TestCase):
|
||||
def test_drops_specified_indices(self):
|
||||
geom = make_multipolygon(
|
||||
[
|
||||
[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]],
|
||||
[[2, 2], [3, 2], [3, 3], [2, 3], [2, 2]],
|
||||
[[4, 4], [5, 4], [5, 5], [4, 5], [4, 4]],
|
||||
]
|
||||
)
|
||||
result = build._drop_parts(geom, [1])
|
||||
assert result["type"] == "MultiPolygon"
|
||||
assert len(result["coordinates"]) == 2
|
||||
# Kept parts: index 0 and index 2
|
||||
assert result["coordinates"][0][0][0] == [0, 0]
|
||||
assert result["coordinates"][1][0][0] == [4, 4]
|
||||
|
||||
def test_polygon_unchanged(self):
|
||||
geom = make_polygon([[0, 0], [1, 0], [1, 1]])
|
||||
result = build._drop_parts(geom, [0])
|
||||
assert result["type"] == "Polygon" # no change
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Transforms
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApplyNameOverrides(unittest.TestCase):
|
||||
def test_applies_to_matching_features_only(self):
|
||||
geo = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"properties": {
|
||||
"adm0_a3": "FRA",
|
||||
"name": "Seien-et-Marne",
|
||||
},
|
||||
"geometry": make_polygon([[0, 0], [1, 1]]),
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"adm0_a3": "FRA",
|
||||
"name": "Paris",
|
||||
},
|
||||
"geometry": make_polygon([[2, 2], [3, 3]]),
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"adm0_a3": "GBR",
|
||||
"name": "Seien-et-Marne", # same string in another country, shouldn't match
|
||||
},
|
||||
"geometry": make_polygon([[4, 4], [5, 5]]),
|
||||
},
|
||||
],
|
||||
}
|
||||
overrides = [
|
||||
{
|
||||
"match": {"adm0_a3": "FRA", "name": "Seien-et-Marne"},
|
||||
"set": {"name": "Seine-et-Marne"},
|
||||
},
|
||||
]
|
||||
build.apply_name_overrides(geo, overrides)
|
||||
assert geo["features"][0]["properties"]["name"] == "Seine-et-Marne"
|
||||
assert geo["features"][1]["properties"]["name"] == "Paris"
|
||||
assert geo["features"][2]["properties"]["name"] == "Seien-et-Marne" # unchanged
|
||||
|
||||
|
||||
class TestApplyFlyingIslands(unittest.TestCase):
|
||||
def _square_at(self, x, y, adm0_a3, name):
|
||||
return {
|
||||
"properties": {"adm0_a3": adm0_a3, "name": name},
|
||||
"geometry": make_polygon(
|
||||
[[x, y], [x + 1, y], [x + 1, y + 1], [x, y + 1], [x, y]]
|
||||
),
|
||||
}
|
||||
|
||||
def test_repositions_matched_features(self):
|
||||
geo = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
self._square_at(0, 0, "USA", "Hawaii"),
|
||||
self._square_at(10, 10, "USA", "Texas"), # not matched
|
||||
],
|
||||
}
|
||||
config = {
|
||||
"countries": {
|
||||
"USA": {
|
||||
"repositions": [
|
||||
{
|
||||
"match": {"name": "Hawaii"},
|
||||
"offset": [100, 200],
|
||||
"scale": 1.0,
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
build.apply_flying_islands(geo, config, country_a3=None, admin_level=1)
|
||||
# Hawaii moved
|
||||
assert geo["features"][0]["geometry"]["coordinates"][0][0] == [100, 200]
|
||||
# Texas unchanged
|
||||
assert geo["features"][1]["geometry"]["coordinates"][0][0] == [10, 10]
|
||||
|
||||
def test_drop_outside_bbox_only_at_admin1(self):
|
||||
geo = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
self._square_at(-50, 50, "NLD", "Caribbean territory"),
|
||||
self._square_at(5, 52, "NLD", "Mainland"),
|
||||
],
|
||||
}
|
||||
config = {
|
||||
"countries": {
|
||||
"NLD": {"drop_outside_bbox": {"nw": [-20, 60], "se": [20, 20]}}
|
||||
}
|
||||
}
|
||||
# Admin 1: drop applies, Caribbean dropped
|
||||
geo_a1 = json.loads(json.dumps(geo))
|
||||
build.apply_flying_islands(geo_a1, config, country_a3=None, admin_level=1)
|
||||
assert len(geo_a1["features"]) == 1
|
||||
assert geo_a1["features"][0]["properties"]["name"] == "Mainland"
|
||||
# Admin 0: drop NOT applied (would otherwise drop entire countries
|
||||
# whose multi-polygons extend overseas)
|
||||
geo_a0 = json.loads(json.dumps(geo))
|
||||
build.apply_flying_islands(geo_a0, config, country_a3=None, admin_level=0)
|
||||
assert len(geo_a0["features"]) == 2
|
||||
|
||||
|
||||
class TestBboxContains(unittest.TestCase):
|
||||
def test_inside_bbox(self):
|
||||
geom = make_polygon([[5, 30], [10, 30], [10, 35], [5, 35], [5, 30]])
|
||||
assert build._bbox_contains(geom, nw=[0, 40], se=[20, 20])
|
||||
|
||||
def test_outside_bbox_west(self):
|
||||
geom = make_polygon([[-30, 30], [-25, 30], [-25, 35], [-30, 35], [-30, 30]])
|
||||
assert not build._bbox_contains(geom, nw=[0, 40], se=[20, 20])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,387 +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 {
|
||||
CSSProperties,
|
||||
FC,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { extent } from 'd3-array';
|
||||
import { rgb } from 'd3-color';
|
||||
import { geoMercator, geoPath } from 'd3-geo';
|
||||
import {
|
||||
getNumberFormatter,
|
||||
getSequentialSchemeRegistry,
|
||||
} from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import { CountryMapTransformedProps } from './types';
|
||||
|
||||
interface FeatureProps {
|
||||
iso_3166_2?: string;
|
||||
adm0_a3?: string;
|
||||
name?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
type Feature = GeoJSON.Feature<GeoJSON.Geometry, FeatureProps>;
|
||||
|
||||
interface TooltipState {
|
||||
x: number;
|
||||
y: number;
|
||||
name: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const containerStyle: CSSProperties = {
|
||||
position: 'relative',
|
||||
fontFamily: 'sans-serif',
|
||||
};
|
||||
|
||||
/**
|
||||
* Pick the property name on a feature that identifies it for data
|
||||
* lookups. Admin 1 uses `iso_3166_2`; Admin 0 uses `adm0_a3`. Some
|
||||
* dissolved/composite features may set their own `iso_3166_2`.
|
||||
*/
|
||||
function featureKey(feature: Feature): string {
|
||||
const p = feature.properties || {};
|
||||
return p.iso_3166_2 || p.adm0_a3 || '';
|
||||
}
|
||||
|
||||
function featureName(feature: Feature, language: string): string {
|
||||
const p = feature.properties || {};
|
||||
// Try language-specific NAME_<lang> first, fall back to `name`.
|
||||
const langKey = `name_${language.toLowerCase()}`;
|
||||
return (p[langKey] as string) || p.name || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a feature collection by include/exclude lists + the
|
||||
* flying-islands toggle.
|
||||
*
|
||||
* - includes: if non-empty, keep ONLY features whose key is in this list
|
||||
* - excludes: drop features whose key is in this list
|
||||
* - showFlyingIslands: when false, drop features tagged as "flying" (the
|
||||
* build pipeline doesn't currently tag these in feature properties; for
|
||||
* the POC we treat the flag as a no-op until tagging is added)
|
||||
*/
|
||||
function filterFeatures(
|
||||
features: Feature[],
|
||||
includes: string[],
|
||||
excludes: string[],
|
||||
): Feature[] {
|
||||
const incSet = new Set(includes);
|
||||
const excSet = new Set(excludes);
|
||||
return features.filter(f => {
|
||||
const k = featureKey(f);
|
||||
if (incSet.size > 0 && !incSet.has(k)) return false;
|
||||
if (excSet.has(k)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const CountryMap: FC<CountryMapTransformedProps> = props => {
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
geoJsonUrl,
|
||||
data,
|
||||
formData,
|
||||
metricName,
|
||||
numberFormat,
|
||||
linearColorScheme,
|
||||
} = props;
|
||||
|
||||
const theme = useTheme();
|
||||
const colors = {
|
||||
fillFallback: theme.colorFillTertiary,
|
||||
schemeFallback: theme.colorFill,
|
||||
hoverFallback: theme.colorFillSecondary,
|
||||
stroke: theme.colorBgContainer,
|
||||
tooltipBg: theme.colorBgSpotlight,
|
||||
tooltipFg: theme.colorTextLightSolid,
|
||||
errorFg: theme.colorErrorText,
|
||||
loadingFg: theme.colorTextSecondary,
|
||||
};
|
||||
const tooltipStyle: CSSProperties = {
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
background: colors.tooltipBg,
|
||||
color: colors.tooltipFg,
|
||||
padding: '4px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
whiteSpace: 'nowrap',
|
||||
transform: 'translate(-50%, -120%)',
|
||||
zIndex: 10,
|
||||
opacity: 0.9,
|
||||
};
|
||||
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const [geo, setGeo] = useState<GeoJSON.FeatureCollection | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
|
||||
// ---- Load GeoJSON ----------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (!geoJsonUrl) {
|
||||
setError(
|
||||
'No GeoJSON URL resolved (check worldview / admin_level / country).',
|
||||
);
|
||||
setGeo(null);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
let cancelled = false;
|
||||
fetch(geoJsonUrl)
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status} fetching ${geoJsonUrl}`);
|
||||
return r.json();
|
||||
})
|
||||
.then((g: GeoJSON.FeatureCollection) => {
|
||||
if (!cancelled) setGeo(g);
|
||||
})
|
||||
.catch(e => {
|
||||
if (!cancelled) setError(String(e));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [geoJsonUrl]);
|
||||
|
||||
// ---- Compute filtered feature set ------------------------------------
|
||||
const filteredFeatures = useMemo<Feature[]>(() => {
|
||||
if (!geo) return [];
|
||||
return filterFeatures(
|
||||
geo.features as Feature[],
|
||||
formData.region_includes ?? [],
|
||||
formData.region_excludes ?? [],
|
||||
);
|
||||
}, [geo, formData.region_includes, formData.region_excludes]);
|
||||
|
||||
// ---- Color scale -----------------------------------------------------
|
||||
const colorByKey = useMemo<Record<string, string>>(() => {
|
||||
if (!data.length || !metricName) return {};
|
||||
const numericData = data
|
||||
.map(d => ({
|
||||
key: String(
|
||||
d[Object.keys(d).find(k => k !== metricName) || 'key'] ?? '',
|
||||
),
|
||||
value:
|
||||
typeof d[metricName] === 'number' ? (d[metricName] as number) : NaN,
|
||||
}))
|
||||
.filter(d => Number.isFinite(d.value));
|
||||
if (!numericData.length) return {};
|
||||
|
||||
const [lo, hi] = extent(numericData, d => d.value) as [number, number];
|
||||
const scheme = linearColorScheme
|
||||
? getSequentialSchemeRegistry().get(linearColorScheme)
|
||||
: null;
|
||||
const linear = scheme
|
||||
? scheme.createLinearScale([lo, hi])
|
||||
: () => colors.schemeFallback;
|
||||
|
||||
const out: Record<string, string> = {};
|
||||
numericData.forEach(d => {
|
||||
out[d.key] = linear(d.value) ?? colors.schemeFallback;
|
||||
});
|
||||
return out;
|
||||
}, [data, metricName, linearColorScheme, colors.schemeFallback]);
|
||||
|
||||
const formatter = useMemo(
|
||||
() =>
|
||||
numberFormat
|
||||
? getNumberFormatter(numberFormat)
|
||||
: (n: number) => String(n),
|
||||
[numberFormat],
|
||||
);
|
||||
|
||||
// ---- Render ----------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg || !filteredFeatures.length) return undefined;
|
||||
|
||||
// Clear previous render
|
||||
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
||||
|
||||
// Compute projection that fits the rendered feature set
|
||||
// (NOT the original geo — fit-to-selection).
|
||||
const featureCollection: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: filteredFeatures,
|
||||
};
|
||||
const projection = geoMercator().fitSize(
|
||||
[width, height],
|
||||
featureCollection,
|
||||
);
|
||||
const path = geoPath(projection);
|
||||
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
|
||||
// Background rect — clicking it zooms back out.
|
||||
const bg = document.createElementNS(ns, 'rect');
|
||||
bg.setAttribute('width', String(width));
|
||||
bg.setAttribute('height', String(height));
|
||||
bg.setAttribute('fill', 'transparent');
|
||||
bg.style.cursor = 'pointer';
|
||||
svg.appendChild(bg);
|
||||
|
||||
const g = document.createElementNS(ns, 'g');
|
||||
g.style.transition = 'transform 600ms ease-in-out';
|
||||
svg.appendChild(g);
|
||||
|
||||
// Click-to-zoom state lives on the svg element so the background
|
||||
// click handler can read/clear it without React state churn.
|
||||
let zoomedFeature: Feature | null = null;
|
||||
const setTransform = (feature: Feature | null) => {
|
||||
if (!feature) {
|
||||
g.setAttribute('transform', '');
|
||||
zoomedFeature = null;
|
||||
return;
|
||||
}
|
||||
const centroid = path.centroid(feature);
|
||||
if (!centroid || centroid.some(Number.isNaN)) return;
|
||||
const k = 4;
|
||||
const tx = width / 2 - centroid[0] * k;
|
||||
const ty = height / 2 - centroid[1] * k;
|
||||
g.setAttribute('transform', `translate(${tx}, ${ty}) scale(${k})`);
|
||||
zoomedFeature = feature;
|
||||
};
|
||||
bg.addEventListener('click', () => setTransform(null));
|
||||
|
||||
filteredFeatures.forEach(feature => {
|
||||
const d = path(feature);
|
||||
if (!d) return;
|
||||
const key = featureKey(feature);
|
||||
const fill = colorByKey[key] || colors.fillFallback;
|
||||
const el = document.createElementNS(ns, 'path');
|
||||
el.setAttribute('d', d);
|
||||
el.setAttribute('fill', fill);
|
||||
el.setAttribute('stroke', colors.stroke);
|
||||
el.setAttribute('stroke-width', '0.5');
|
||||
el.setAttribute('vector-effect', 'non-scaling-stroke');
|
||||
el.style.cursor = 'pointer';
|
||||
el.style.transition = 'fill 120ms';
|
||||
|
||||
el.addEventListener('mouseenter', () => {
|
||||
const c = colorByKey[key];
|
||||
const darker = c ? rgb(c).darker(0.5).toString() : colors.hoverFallback;
|
||||
el.setAttribute('fill', darker);
|
||||
});
|
||||
el.addEventListener('mouseleave', () => {
|
||||
el.setAttribute('fill', colorByKey[key] || colors.fillFallback);
|
||||
setTooltip(null);
|
||||
});
|
||||
el.addEventListener('click', (event: globalThis.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
setTransform(zoomedFeature === feature ? null : feature);
|
||||
});
|
||||
el.addEventListener('mousemove', (event: globalThis.MouseEvent) => {
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const dataRow = data.find(d => {
|
||||
const idCol = Object.keys(d).find(k => k !== metricName) || 'key';
|
||||
return String(d[idCol]) === key;
|
||||
});
|
||||
const value =
|
||||
dataRow && metricName && typeof dataRow[metricName] === 'number'
|
||||
? formatter(dataRow[metricName] as number)
|
||||
: null;
|
||||
setTooltip({
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
name: featureName(feature, formData.name_language || 'en'),
|
||||
value,
|
||||
});
|
||||
});
|
||||
|
||||
g.appendChild(el);
|
||||
});
|
||||
|
||||
return () => {
|
||||
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
||||
};
|
||||
}, [
|
||||
filteredFeatures,
|
||||
width,
|
||||
height,
|
||||
colorByKey,
|
||||
data,
|
||||
metricName,
|
||||
formatter,
|
||||
formData.name_language,
|
||||
]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...containerStyle,
|
||||
width,
|
||||
height,
|
||||
padding: 16,
|
||||
color: colors.errorFg,
|
||||
}}
|
||||
>
|
||||
{t('Error loading map:')} {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ ...containerStyle, width, height }}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ display: 'block' }}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
onMouseLeave={(_e: ReactMouseEvent) => setTooltip(null)}
|
||||
/>
|
||||
{tooltip && (
|
||||
<div style={{ ...tooltipStyle, left: tooltip.x, top: tooltip.y }}>
|
||||
<strong>{tooltip.name}</strong>
|
||||
{tooltip.value !== null && (
|
||||
<>
|
||||
<br />
|
||||
{tooltip.value}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!geo && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
fontSize: 11,
|
||||
color: colors.loadingFg,
|
||||
}}
|
||||
>
|
||||
{t('Loading map…')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CountryMap;
|
||||
@@ -1,323 +0,0 @@
|
||||
{
|
||||
"ne_pinned_tag": "v5.1.2",
|
||||
"ne_pinned_sha": "f1890d9f152c896d250a77557a5751a93d494776",
|
||||
"worldviews": [
|
||||
"arg",
|
||||
"bdg",
|
||||
"bra",
|
||||
"chn",
|
||||
"default",
|
||||
"deu",
|
||||
"egy",
|
||||
"esp",
|
||||
"fra",
|
||||
"gbr",
|
||||
"grc",
|
||||
"idn",
|
||||
"ind",
|
||||
"iso",
|
||||
"isr",
|
||||
"ita",
|
||||
"jpn",
|
||||
"kor",
|
||||
"mar",
|
||||
"nep",
|
||||
"nld",
|
||||
"pak",
|
||||
"pol",
|
||||
"prt",
|
||||
"pse",
|
||||
"rus",
|
||||
"sau",
|
||||
"swe",
|
||||
"tur",
|
||||
"twn",
|
||||
"ukr",
|
||||
"usa",
|
||||
"vnm"
|
||||
],
|
||||
"admin_levels": [0, 1],
|
||||
"countries_by_worldview": {
|
||||
"arg": [],
|
||||
"bdg": [],
|
||||
"bra": [],
|
||||
"chn": [],
|
||||
"default": [],
|
||||
"deu": [],
|
||||
"egy": [],
|
||||
"esp": [],
|
||||
"fra": [],
|
||||
"gbr": [],
|
||||
"grc": [],
|
||||
"idn": [],
|
||||
"ind": [],
|
||||
"iso": [],
|
||||
"isr": [],
|
||||
"ita": [],
|
||||
"jpn": [],
|
||||
"kor": [],
|
||||
"mar": [],
|
||||
"nep": [],
|
||||
"nld": [],
|
||||
"pak": [],
|
||||
"pol": [],
|
||||
"prt": [],
|
||||
"pse": [],
|
||||
"rus": [],
|
||||
"sau": [],
|
||||
"swe": [],
|
||||
"tur": [],
|
||||
"twn": [],
|
||||
"ukr": [
|
||||
"AFG",
|
||||
"AGO",
|
||||
"AIA",
|
||||
"ALB",
|
||||
"ALD",
|
||||
"AND",
|
||||
"ARE",
|
||||
"ARG",
|
||||
"ARM",
|
||||
"ASM",
|
||||
"ATA",
|
||||
"ATF",
|
||||
"ATG",
|
||||
"AUS",
|
||||
"AUT",
|
||||
"AZE",
|
||||
"BDI",
|
||||
"BEL",
|
||||
"BEN",
|
||||
"BFA",
|
||||
"BGD",
|
||||
"BGR",
|
||||
"BHR",
|
||||
"BHS",
|
||||
"BIH",
|
||||
"BLR",
|
||||
"BLZ",
|
||||
"BMU",
|
||||
"BOL",
|
||||
"BRA",
|
||||
"BRB",
|
||||
"BRN",
|
||||
"BTN",
|
||||
"BWA",
|
||||
"CAF",
|
||||
"CAN",
|
||||
"CHE",
|
||||
"CHL",
|
||||
"CHN",
|
||||
"CIV",
|
||||
"CMR",
|
||||
"COD",
|
||||
"COG",
|
||||
"COK",
|
||||
"COL",
|
||||
"COM",
|
||||
"CPV",
|
||||
"CRI",
|
||||
"CUB",
|
||||
"CYP",
|
||||
"CZE",
|
||||
"DEU",
|
||||
"DJI",
|
||||
"DMA",
|
||||
"DNK",
|
||||
"DOM",
|
||||
"DZA",
|
||||
"ECU",
|
||||
"EGY",
|
||||
"ERI",
|
||||
"ESP",
|
||||
"EST",
|
||||
"ETH",
|
||||
"FIN",
|
||||
"FJI",
|
||||
"FRA",
|
||||
"FSM",
|
||||
"GAB",
|
||||
"GBR",
|
||||
"GEO",
|
||||
"GHA",
|
||||
"GIN",
|
||||
"GMB",
|
||||
"GNB",
|
||||
"GNQ",
|
||||
"GRC",
|
||||
"GRD",
|
||||
"GRL",
|
||||
"GTM",
|
||||
"GUY",
|
||||
"HKG",
|
||||
"HND",
|
||||
"HRV",
|
||||
"HTI",
|
||||
"HUN",
|
||||
"IDN",
|
||||
"IND",
|
||||
"IOA",
|
||||
"IRL",
|
||||
"IRN",
|
||||
"IRQ",
|
||||
"ISL",
|
||||
"ISR",
|
||||
"ITA",
|
||||
"JAM",
|
||||
"JOR",
|
||||
"JPN",
|
||||
"KAZ",
|
||||
"KEN",
|
||||
"KGZ",
|
||||
"KHM",
|
||||
"KIR",
|
||||
"KNA",
|
||||
"KOR",
|
||||
"KOS",
|
||||
"KWT",
|
||||
"LAO",
|
||||
"LBN",
|
||||
"LBR",
|
||||
"LBY",
|
||||
"LCA",
|
||||
"LIE",
|
||||
"LKA",
|
||||
"LSO",
|
||||
"LTU",
|
||||
"LUX",
|
||||
"LVA",
|
||||
"MAR",
|
||||
"MDA",
|
||||
"MDG",
|
||||
"MDV",
|
||||
"MEX",
|
||||
"MHL",
|
||||
"MKD",
|
||||
"MLI",
|
||||
"MLT",
|
||||
"MMR",
|
||||
"MNE",
|
||||
"MNG",
|
||||
"MNP",
|
||||
"MOZ",
|
||||
"MRT",
|
||||
"MSR",
|
||||
"MUS",
|
||||
"MWI",
|
||||
"MYS",
|
||||
"NAM",
|
||||
"NCL",
|
||||
"NER",
|
||||
"NGA",
|
||||
"NIC",
|
||||
"NLD",
|
||||
"NOR",
|
||||
"NPL",
|
||||
"NRU",
|
||||
"NZL",
|
||||
"OMN",
|
||||
"PAK",
|
||||
"PAN",
|
||||
"PER",
|
||||
"PHL",
|
||||
"PLW",
|
||||
"PNG",
|
||||
"POL",
|
||||
"PRK",
|
||||
"PRT",
|
||||
"PRY",
|
||||
"PSX",
|
||||
"PYF",
|
||||
"QAT",
|
||||
"ROU",
|
||||
"RUS",
|
||||
"RWA",
|
||||
"SAU",
|
||||
"SDN",
|
||||
"SDS",
|
||||
"SEN",
|
||||
"SGP",
|
||||
"SHN",
|
||||
"SLB",
|
||||
"SLE",
|
||||
"SLV",
|
||||
"SMR",
|
||||
"SOM",
|
||||
"SPM",
|
||||
"SRB",
|
||||
"STP",
|
||||
"SUR",
|
||||
"SVK",
|
||||
"SVN",
|
||||
"SWE",
|
||||
"SWZ",
|
||||
"SYC",
|
||||
"SYR",
|
||||
"TCA",
|
||||
"TCD",
|
||||
"TGO",
|
||||
"THA",
|
||||
"TJK",
|
||||
"TKM",
|
||||
"TLS",
|
||||
"TON",
|
||||
"TTO",
|
||||
"TUN",
|
||||
"TUR",
|
||||
"TWN",
|
||||
"TZA",
|
||||
"UGA",
|
||||
"UKR",
|
||||
"UMI",
|
||||
"URY",
|
||||
"USA",
|
||||
"UZB",
|
||||
"VCT",
|
||||
"VEN",
|
||||
"VIR",
|
||||
"VNM",
|
||||
"VUT",
|
||||
"WLF",
|
||||
"WSM",
|
||||
"YEM",
|
||||
"ZAF",
|
||||
"ZMB",
|
||||
"ZWE"
|
||||
],
|
||||
"usa": [],
|
||||
"vnm": []
|
||||
},
|
||||
"regional_aggregations": [
|
||||
{
|
||||
"country": "FRA",
|
||||
"set_id": "regions",
|
||||
"worldview": "ukr",
|
||||
"size_bytes": 32077
|
||||
},
|
||||
{
|
||||
"country": "ITA",
|
||||
"set_id": "regions",
|
||||
"worldview": "ukr",
|
||||
"size_bytes": 32116
|
||||
},
|
||||
{
|
||||
"country": "PHL",
|
||||
"set_id": "regions",
|
||||
"worldview": "ukr",
|
||||
"size_bytes": 31805
|
||||
},
|
||||
{
|
||||
"country": "TUR",
|
||||
"set_id": "nuts_1",
|
||||
"worldview": "ukr",
|
||||
"size_bytes": 23036
|
||||
}
|
||||
],
|
||||
"composites": [
|
||||
{
|
||||
"id": "france_overseas",
|
||||
"worldview": "ukr",
|
||||
"size_bytes": 322058
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,21 +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-next-line import/prefer-default-export
|
||||
export { default as CountryMapChartPlugin } from './plugin';
|
||||
export * from './types';
|
||||
@@ -1,41 +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 {
|
||||
buildQueryContext,
|
||||
normalizeOrderBy,
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
/**
|
||||
* The new country map uses the modern chart/data endpoint via
|
||||
* buildQueryContext (the legacy plugin used explore_json directly).
|
||||
*
|
||||
* The data query itself is straightforward: one row per region
|
||||
* (matched against the GeoJSON's iso_3166_2 / adm0_a3 properties),
|
||||
* one metric column. The geographic data is loaded separately on the
|
||||
* client from the build pipeline's GeoJSON outputs.
|
||||
*/
|
||||
export default function buildQuery(formData: QueryFormData) {
|
||||
return buildQueryContext(formData, baseQueryObject => [
|
||||
{
|
||||
...baseQueryObject,
|
||||
orderby: normalizeOrderBy(baseQueryObject).orderby,
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -1,564 +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 { t } from '@apache-superset/core/translation';
|
||||
import { validateNonEmpty } from '@superset-ui/core';
|
||||
import {
|
||||
ControlPanelConfig,
|
||||
D3_FORMAT_DOCS,
|
||||
D3_FORMAT_OPTIONS,
|
||||
getStandardizedControls,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import manifest from '../data/manifest.json';
|
||||
import migrateFromLegacy from './migrateFromLegacy';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Choice tables — sourced from the build pipeline's manifest.json
|
||||
//
|
||||
// The manifest is regenerated by every `./scripts/build.sh` run; adding
|
||||
// a new worldview or country to the YAML configs and re-running build
|
||||
// makes the new option appear in the UI without touching this file.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
interface CountryMapManifest {
|
||||
worldviews: string[];
|
||||
admin_levels: number[];
|
||||
countries_by_worldview: Record<string, string[]>;
|
||||
regional_aggregations: Array<{
|
||||
country: string;
|
||||
set_id: string;
|
||||
worldview: string;
|
||||
size_bytes: number;
|
||||
}>;
|
||||
composites: Array<{ id: string; worldview: string; size_bytes: number }>;
|
||||
}
|
||||
|
||||
const M = manifest as CountryMapManifest;
|
||||
|
||||
// Display-name labels for worldviews. Anything not listed here renders
|
||||
// as the worldview code itself in the dropdown. The list of *available*
|
||||
// worldviews is driven by the manifest (= what the build pipeline
|
||||
// produced), not this map — so a worldview added to scripts/build.py
|
||||
// and re-built will appear in the dropdown automatically, just with a
|
||||
// raw code label until you add an entry here.
|
||||
const WORLDVIEW_LABELS: Record<string, string> = {
|
||||
default: t('Natural Earth Default'),
|
||||
arg: t('Argentina'),
|
||||
bdg: t('Bangladesh'),
|
||||
bra: t('Brazil'),
|
||||
chn: t('China'),
|
||||
deu: t('Germany'),
|
||||
egy: t('Egypt'),
|
||||
esp: t('Spain'),
|
||||
fra: t('France'),
|
||||
gbr: t('United Kingdom'),
|
||||
grc: t('Greece'),
|
||||
idn: t('Indonesia'),
|
||||
ind: t('India'),
|
||||
iso: t('ISO 3166 (UN-style)'),
|
||||
isr: t('Israel'),
|
||||
ita: t('Italy'),
|
||||
jpn: t('Japan'),
|
||||
kor: t('South Korea'),
|
||||
mar: t('Morocco'),
|
||||
nep: t('Nepal'),
|
||||
nld: t('Netherlands'),
|
||||
pak: t('Pakistan'),
|
||||
pol: t('Poland'),
|
||||
prt: t('Portugal'),
|
||||
pse: t('Palestine'),
|
||||
rus: t('Russia'),
|
||||
sau: t('Saudi Arabia'),
|
||||
swe: t('Sweden'),
|
||||
tur: t('Türkiye'),
|
||||
twn: t('Taiwan'),
|
||||
ukr: t('Ukraine (Superset default — Crimea as Ukrainian)'),
|
||||
usa: t('United States'),
|
||||
vnm: t('Vietnam'),
|
||||
};
|
||||
|
||||
const WORLDVIEW_CHOICES: Array<[string, string]> = M.worldviews.map(wv => [
|
||||
wv,
|
||||
WORLDVIEW_LABELS[wv] || wv,
|
||||
]);
|
||||
|
||||
// Map scope choices. The underlying values stay 0/1/aggregated so saved
|
||||
// charts and form_data don't break — only the labels change for clarity.
|
||||
const ADMIN_LEVEL_CHOICES: Array<[string, string]> = [
|
||||
[String(0), t('World')],
|
||||
[String(1), t('Country')],
|
||||
['aggregated', t('Aggregated regions')],
|
||||
];
|
||||
|
||||
// English country names by ISO_A3, for friendly dropdown labels. Codes
|
||||
// not listed here render as the raw ISO code (rare; missing entries
|
||||
// here aren't a correctness problem).
|
||||
const COUNTRY_LABELS: Record<string, string> = {
|
||||
AFG: 'Afghanistan',
|
||||
ARG: 'Argentina',
|
||||
AUS: 'Australia',
|
||||
AUT: 'Austria',
|
||||
BEL: 'Belgium',
|
||||
BGD: 'Bangladesh',
|
||||
BGR: 'Bulgaria',
|
||||
BIH: 'Bosnia and Herzegovina',
|
||||
BLR: 'Belarus',
|
||||
BOL: 'Bolivia',
|
||||
BRA: 'Brazil',
|
||||
CAN: 'Canada',
|
||||
CHE: 'Switzerland',
|
||||
CHL: 'Chile',
|
||||
CHN: 'China',
|
||||
COL: 'Colombia',
|
||||
CRI: 'Costa Rica',
|
||||
CUB: 'Cuba',
|
||||
CYP: 'Cyprus',
|
||||
CZE: 'Czechia',
|
||||
DEU: 'Germany',
|
||||
DNK: 'Denmark',
|
||||
DOM: 'Dominican Republic',
|
||||
ECU: 'Ecuador',
|
||||
EGY: 'Egypt',
|
||||
ESP: 'Spain',
|
||||
EST: 'Estonia',
|
||||
ETH: 'Ethiopia',
|
||||
FIN: 'Finland',
|
||||
FRA: 'France',
|
||||
GBR: 'United Kingdom',
|
||||
GHA: 'Ghana',
|
||||
GRC: 'Greece',
|
||||
GTM: 'Guatemala',
|
||||
HND: 'Honduras',
|
||||
HRV: 'Croatia',
|
||||
HUN: 'Hungary',
|
||||
IDN: 'Indonesia',
|
||||
IND: 'India',
|
||||
IRL: 'Ireland',
|
||||
IRN: 'Iran',
|
||||
IRQ: 'Iraq',
|
||||
ISL: 'Iceland',
|
||||
ISR: 'Israel',
|
||||
ITA: 'Italy',
|
||||
JPN: 'Japan',
|
||||
KAZ: 'Kazakhstan',
|
||||
KEN: 'Kenya',
|
||||
KGZ: 'Kyrgyzstan',
|
||||
KHM: 'Cambodia',
|
||||
KOR: 'South Korea',
|
||||
LAO: 'Laos',
|
||||
LBN: 'Lebanon',
|
||||
LTU: 'Lithuania',
|
||||
LVA: 'Latvia',
|
||||
MAR: 'Morocco',
|
||||
MEX: 'Mexico',
|
||||
MMR: 'Myanmar',
|
||||
MNG: 'Mongolia',
|
||||
MYS: 'Malaysia',
|
||||
NGA: 'Nigeria',
|
||||
NLD: 'Netherlands',
|
||||
NOR: 'Norway',
|
||||
NPL: 'Nepal',
|
||||
NZL: 'New Zealand',
|
||||
PAK: 'Pakistan',
|
||||
PER: 'Peru',
|
||||
PHL: 'Philippines',
|
||||
POL: 'Poland',
|
||||
PRT: 'Portugal',
|
||||
PRY: 'Paraguay',
|
||||
ROU: 'Romania',
|
||||
RUS: 'Russia',
|
||||
SAU: 'Saudi Arabia',
|
||||
SDN: 'Sudan',
|
||||
SRB: 'Serbia',
|
||||
SVK: 'Slovakia',
|
||||
SVN: 'Slovenia',
|
||||
SWE: 'Sweden',
|
||||
SYR: 'Syria',
|
||||
THA: 'Thailand',
|
||||
TUR: 'Türkiye',
|
||||
TWN: 'Taiwan',
|
||||
UKR: 'Ukraine',
|
||||
URY: 'Uruguay',
|
||||
USA: 'United States',
|
||||
UZB: 'Uzbekistan',
|
||||
VEN: 'Venezuela',
|
||||
VNM: 'Vietnam',
|
||||
YEM: 'Yemen',
|
||||
ZAF: 'South Africa',
|
||||
ZWE: 'Zimbabwe',
|
||||
};
|
||||
|
||||
const formatCountry = (code: string): string =>
|
||||
COUNTRY_LABELS[code] ? `${COUNTRY_LABELS[code]} (${code})` : code;
|
||||
|
||||
// Build country choices from the union of all worldview manifests
|
||||
// (typically the same set since Admin 1 is one global file, but
|
||||
// future per-worldview Admin 1 outputs would naturally differ).
|
||||
const COUNTRY_CHOICES: Array<[string, string]> = (() => {
|
||||
const all = new Set<string>();
|
||||
Object.values(M.countries_by_worldview).forEach(codes =>
|
||||
codes.forEach(c => all.add(c)),
|
||||
);
|
||||
return Array.from(all)
|
||||
.sort()
|
||||
.map<[string, string]>(c => [c, formatCountry(c)]);
|
||||
})();
|
||||
|
||||
// Region-set labels keyed by `<set_id>` (TUR's `nuts_1` etc.).
|
||||
const REGION_SET_LABELS: Record<string, string> = {
|
||||
nuts_1: t('NUTS-1 statistical regions'),
|
||||
regions: t('Administrative regions'),
|
||||
};
|
||||
|
||||
// Build {country: [(set_id, label), ...]} from manifest.
|
||||
const REGION_SET_CHOICES_BY_COUNTRY: Record<
|
||||
string,
|
||||
Array<[string, string]>
|
||||
> = (() => {
|
||||
const out: Record<string, Array<[string, string]>> = {};
|
||||
M.regional_aggregations.forEach(r => {
|
||||
out[r.country] = out[r.country] || [];
|
||||
out[r.country].push([r.set_id, REGION_SET_LABELS[r.set_id] || r.set_id]);
|
||||
});
|
||||
return out;
|
||||
})();
|
||||
|
||||
// Composite-map labels keyed by `<id>`.
|
||||
const COMPOSITE_LABELS: Record<string, string> = {
|
||||
france_overseas: t('France (with overseas territories)'),
|
||||
};
|
||||
|
||||
const COMPOSITE_CHOICES: Array<[string, string]> = M.composites.map(c => [
|
||||
c.id,
|
||||
COMPOSITE_LABELS[c.id] || c.id,
|
||||
]);
|
||||
|
||||
// NE NAME_<lang> language codes available across most features.
|
||||
const NAME_LANGUAGE_CHOICES: Array<[string, string]> = [
|
||||
['en', t('English (en)')],
|
||||
['fr', t('French (fr)')],
|
||||
['de', t('German (de)')],
|
||||
['es', t('Spanish (es)')],
|
||||
['it', t('Italian (it)')],
|
||||
['pt', t('Portuguese (pt)')],
|
||||
['ru', t('Russian (ru)')],
|
||||
['zh', t('Chinese (zh)')],
|
||||
['ja', t('Japanese (ja)')],
|
||||
['ko', t('Korean (ko)')],
|
||||
['vi', t('Vietnamese (vi)')],
|
||||
['ar', t('Arabic (ar)')],
|
||||
['hi', t('Hindi (hi)')],
|
||||
['fa', t('Persian (fa)')],
|
||||
['tr', t('Turkish (tr)')],
|
||||
['nl', t('Dutch (nl)')],
|
||||
['pl', t('Polish (pl)')],
|
||||
['sv', t('Swedish (sv)')],
|
||||
['el', t('Greek (el)')],
|
||||
['he', t('Hebrew (he)')],
|
||||
];
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Visibility helpers
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const isAdminCountry = (controls: Record<string, { value?: unknown }>) =>
|
||||
controls.admin_level?.value === String(0) ||
|
||||
controls.admin_level?.value === 0;
|
||||
const isAdminAggregated = (controls: Record<string, { value?: unknown }>) =>
|
||||
controls.admin_level?.value === 'aggregated';
|
||||
const hasComposite = (controls: Record<string, { value?: unknown }>) =>
|
||||
Boolean(controls.composite?.value);
|
||||
|
||||
// A country selection is only *required* when the user is rendering
|
||||
// subdivisions or an aggregated layer AND has not picked a composite.
|
||||
// At Admin 0 (world choropleth) or with a composite set, country is moot.
|
||||
const needsCountry = (controls: Record<string, { value?: unknown }>) =>
|
||||
!isAdminCountry(controls) && !hasComposite(controls);
|
||||
|
||||
const config: ControlPanelConfig = {
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: t('Map'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'worldview',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t('Worldview'),
|
||||
description: t(
|
||||
'Cartographic perspective for disputed regions. ' +
|
||||
'Defaults to Ukraine worldview (Crimea shown as Ukrainian); ' +
|
||||
'override per-deployment in superset_config.COUNTRY_MAP.default_worldview.',
|
||||
),
|
||||
choices: WORLDVIEW_CHOICES,
|
||||
default: 'ukr',
|
||||
renderTrigger: true,
|
||||
clearable: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'admin_level',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t('Map view'),
|
||||
description: t(
|
||||
'World shows all countries; Country shows subdivisions of ' +
|
||||
'one country (states/provinces/departments); Aggregated ' +
|
||||
"regions dissolves a country's subdivisions into coarser " +
|
||||
'administrative regions (e.g. French regions, Turkish ' +
|
||||
'NUTS-1 regions). Stored as admin_level (0 / 1 / aggregated).',
|
||||
),
|
||||
choices: ADMIN_LEVEL_CHOICES,
|
||||
default: String(0),
|
||||
renderTrigger: true,
|
||||
clearable: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'country',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t('Country'),
|
||||
description: t('Which country to plot subdivisions for.'),
|
||||
choices: COUNTRY_CHOICES,
|
||||
default: null,
|
||||
renderTrigger: true,
|
||||
clearable: false,
|
||||
// Country is only required when the current admin_level /
|
||||
// composite combination actually consumes it. Without this,
|
||||
// a hidden empty country traps the user with a permanent
|
||||
// "Country: cannot be empty" badge on the Data tab even
|
||||
// though there's no Country control on that tab.
|
||||
mapStateToProps: ({ controls }: any) => ({
|
||||
validators: needsCountry(controls) ? [validateNonEmpty] : [],
|
||||
}),
|
||||
visibility: ({ controls }: any) => needsCountry(controls),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'region_set',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t('Aggregated region set'),
|
||||
description: t(
|
||||
'Which administrative region layer to dissolve into. ' +
|
||||
'Available sets depend on the selected country.',
|
||||
),
|
||||
default: null,
|
||||
renderTrigger: true,
|
||||
clearable: true,
|
||||
// SelectControl's `choices` must be a literal array, not a
|
||||
// function. Use mapStateToProps to derive choices from the
|
||||
// currently selected country at render time.
|
||||
mapStateToProps: ({ controls }: any) => ({
|
||||
choices:
|
||||
REGION_SET_CHOICES_BY_COUNTRY[
|
||||
String(controls.country?.value || '')
|
||||
] || [],
|
||||
}),
|
||||
visibility: ({ controls }: any) => isAdminAggregated(controls),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'composite',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t('Composite map'),
|
||||
description: t(
|
||||
'Multi-country composite (e.g. France with overseas territories). ' +
|
||||
'Only relevant at subdivision/aggregated views; overrides ' +
|
||||
'admin level + country when set.',
|
||||
),
|
||||
choices: COMPOSITE_CHOICES,
|
||||
default: null,
|
||||
renderTrigger: true,
|
||||
clearable: true,
|
||||
// Hide when nothing is composite-shaped — at Admin 0 (world
|
||||
// map) it would override the world choropleth, and if the
|
||||
// build pipeline didn't emit any composites there's nothing
|
||||
// to pick. Also leaves room for future per-country scoping.
|
||||
visibility: ({ controls }: any) =>
|
||||
COMPOSITE_CHOICES.length > 0 && !isAdminCountry(controls),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'region_includes',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
multi: true,
|
||||
freeForm: true,
|
||||
label: t('Include only regions'),
|
||||
description: t(
|
||||
'Comma-separated ISO codes (iso_3166_2 or adm0_a3). ' +
|
||||
'When set, only these features are rendered. Projection ' +
|
||||
'auto-fits to the included set.',
|
||||
),
|
||||
choices: [],
|
||||
default: [],
|
||||
renderTrigger: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'region_excludes',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
multi: true,
|
||||
freeForm: true,
|
||||
label: t('Exclude regions'),
|
||||
description: t(
|
||||
'Comma-separated ISO codes to drop from the rendered map.',
|
||||
),
|
||||
choices: [],
|
||||
default: [],
|
||||
renderTrigger: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
// `show_flying_islands` is intentionally absent from this control
|
||||
// set: the build pipeline already repositions known flying-islands
|
||||
// groups (Hawaii, Alaska, French overseas territories, ...) at
|
||||
// build time per `flying_islands.yaml`, and the runtime drop
|
||||
// toggle requires per-feature `_flying: true` tags which the
|
||||
// build doesn't currently emit. Bringing the control back is a
|
||||
// single-line follow-up once that tagging lands — see the SIP.
|
||||
[
|
||||
{
|
||||
name: 'name_language',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t('Name language'),
|
||||
description: t(
|
||||
'Which language to use for displayed region names. ' +
|
||||
"Falls back to English when the requested language isn't " +
|
||||
'available for a feature.',
|
||||
),
|
||||
choices: NAME_LANGUAGE_CHOICES,
|
||||
default: 'en',
|
||||
renderTrigger: true,
|
||||
clearable: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Query'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'entity',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t('ISO code column'),
|
||||
description: t(
|
||||
'Column in your dataset containing ISO codes that match ' +
|
||||
'features in the chosen map (iso_3166_2 for subdivisions, ' +
|
||||
'adm0_a3 for countries).',
|
||||
),
|
||||
mapStateToProps: (state: any) => ({
|
||||
choices: (state.datasource?.columns ?? []).map((c: any) => [
|
||||
c.column_name,
|
||||
c.column_name,
|
||||
]),
|
||||
}),
|
||||
validators: [validateNonEmpty],
|
||||
},
|
||||
},
|
||||
],
|
||||
['metric'],
|
||||
['adhoc_filters'],
|
||||
['row_limit'],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Chart Options'),
|
||||
expanded: true,
|
||||
tabOverride: 'customize',
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'number_format',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
freeForm: true,
|
||||
label: t('Number format'),
|
||||
renderTrigger: true,
|
||||
default: 'SMART_NUMBER',
|
||||
choices: D3_FORMAT_OPTIONS,
|
||||
description: D3_FORMAT_DOCS,
|
||||
},
|
||||
},
|
||||
],
|
||||
['linear_color_scheme'],
|
||||
],
|
||||
},
|
||||
],
|
||||
controlOverrides: {
|
||||
entity: {
|
||||
label: t('ISO code column'),
|
||||
description: t(
|
||||
'Column containing ISO codes of region/province/department in your dataset.',
|
||||
),
|
||||
},
|
||||
linear_color_scheme: {
|
||||
renderTrigger: true,
|
||||
},
|
||||
},
|
||||
// formDataOverrides runs when the user switches a chart's viz_type to
|
||||
// this plugin. We use it for two jobs:
|
||||
// 1. Standard control hand-off (entity, metric) via getStandardizedControls
|
||||
// 2. Migration from the legacy country_map plugin — translate the
|
||||
// legacy `select_country` value into admin_level / country /
|
||||
// composite / region_set so the new chart lands pre-populated
|
||||
// rather than dumping the user back to an empty Country dropdown.
|
||||
formDataOverrides: formData => {
|
||||
const fromLegacy =
|
||||
typeof formData.select_country === 'string' && formData.select_country
|
||||
? migrateFromLegacy(formData)
|
||||
: {};
|
||||
return {
|
||||
...formData,
|
||||
entity: getStandardizedControls().shiftColumn(),
|
||||
metric: getStandardizedControls().shiftMetric(),
|
||||
// Only fill fields the user has not already set on the new chart;
|
||||
// explicit user edits on the new viz win over legacy migration.
|
||||
...Object.fromEntries(
|
||||
Object.entries(fromLegacy).filter(
|
||||
([k]) => formData[k as keyof typeof formData] == null,
|
||||
),
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,66 +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 { t } from '@apache-superset/core/translation';
|
||||
import { ChartMetadata, ChartPlugin } from '@superset-ui/core';
|
||||
import buildQuery from './buildQuery';
|
||||
import controlPanel from './controlPanel';
|
||||
import transformProps from './transformProps';
|
||||
|
||||
/**
|
||||
* Modern Country Map plugin.
|
||||
*
|
||||
* Replaces `legacy-plugin-chart-country-map`. Built against the
|
||||
* `chart/data` endpoint with full async/caching/semantic-layer
|
||||
* integration. Data driven by the build pipeline at
|
||||
* `superset-frontend/plugins/plugin-chart-country-map/scripts/`.
|
||||
*
|
||||
* Default editorial position: ships Natural Earth's `_ukr` worldview,
|
||||
* configurable via `superset_config.COUNTRY_MAP.default_worldview`.
|
||||
* See `SIP_DRAFT.md` for design rationale and discussion of disputed
|
||||
* regions.
|
||||
*/
|
||||
export default class CountryMapChartPlugin extends ChartPlugin {
|
||||
constructor() {
|
||||
const metadata = new ChartMetadata({
|
||||
category: t('Map'),
|
||||
credits: ['Natural Earth (https://www.naturalearthdata.com/)'],
|
||||
description: t(
|
||||
"Visualizes a metric across a country's principal subdivisions " +
|
||||
'(states, provinces, departments, etc.) on a choropleth map. ' +
|
||||
'Supports configurable worldview for disputed regions, multi-' +
|
||||
'country composites (e.g. France with overseas territories), ' +
|
||||
'and aggregated regional layers.',
|
||||
),
|
||||
name: t('Country Map'),
|
||||
tags: [t('2D'), t('Comparison'), t('Geo'), t('Range'), t('Report')],
|
||||
// TODO: thumbnail + example images come in a follow-up commit
|
||||
// (need to render real outputs first).
|
||||
thumbnail: '',
|
||||
});
|
||||
|
||||
super({
|
||||
buildQuery,
|
||||
controlPanel,
|
||||
// Lazy-load the React renderer to keep the chart-type registry small.
|
||||
loadChart: () => import('../CountryMap'),
|
||||
metadata,
|
||||
transformProps,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,308 +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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Translate a legacy `country_map` chart's form_data into the new
|
||||
* `country_map_v2` form_data shape.
|
||||
*
|
||||
* Triggered from controlPanel.formDataOverrides whenever a user switches
|
||||
* a saved chart's viz type to country_map_v2. We try to preserve as much
|
||||
* intent as possible:
|
||||
*
|
||||
* - Legacy `select_country: 'france'` → admin_level=1, country='FRA'
|
||||
* - Legacy `select_country: 'france_overseas'` → composite='france_overseas'
|
||||
* - Legacy `select_country: 'turkey_regions'` → admin_level='aggregated',
|
||||
* country='TUR', region_set='nuts_1'
|
||||
* - Legacy `select_country: 'italy_regions'` → ITA/regions
|
||||
* - Legacy `select_country: 'philippines_regions'` → PHL/regions
|
||||
* - Legacy `select_country: 'france_regions'` → FRA/regions
|
||||
*
|
||||
* Worldview defaults to 'ukr' (the new plugin's default editorial choice).
|
||||
* Standard controls (entity, metric, color scheme, number format) flow
|
||||
* through the standard `formDataOverrides` path; this migration only
|
||||
* touches the country/admin/composite/region_set quartet.
|
||||
*/
|
||||
|
||||
interface PartialFormData {
|
||||
select_country?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
interface MigrationOutput {
|
||||
admin_level?: string;
|
||||
country?: string;
|
||||
composite?: string;
|
||||
region_set?: string;
|
||||
worldview?: string;
|
||||
}
|
||||
|
||||
// Composite outputs — legacy keys that should map to the new plugin's
|
||||
// composite_maps.yaml-driven composite control rather than to a country.
|
||||
const LEGACY_TO_COMPOSITE: Record<string, string> = {
|
||||
france_overseas: 'france_overseas',
|
||||
};
|
||||
|
||||
// Aggregated region outputs — legacy keys that should map to the new
|
||||
// plugin's regional_aggregations.yaml-driven country+region_set pair.
|
||||
const LEGACY_TO_AGGREGATED: Record<
|
||||
string,
|
||||
{ country: string; region_set: string }
|
||||
> = {
|
||||
france_regions: { country: 'FRA', region_set: 'regions' },
|
||||
italy_regions: { country: 'ITA', region_set: 'regions' },
|
||||
philippines_regions: { country: 'PHL', region_set: 'regions' },
|
||||
turkey_regions: { country: 'TUR', region_set: 'nuts_1' },
|
||||
};
|
||||
|
||||
// Per-country subdivisions — legacy snake_case keys mapped to ISO 3166-1
|
||||
// alpha-3 codes used by the new plugin's country control. Coverage is
|
||||
// intentionally broad — every legacy country file maps to a sibling
|
||||
// entry in the new build's admin 1 outputs.
|
||||
const LEGACY_TO_ISO_A3: Record<string, string> = {
|
||||
afghanistan: 'AFG',
|
||||
aland: 'ALD',
|
||||
albania: 'ALB',
|
||||
algeria: 'DZA',
|
||||
american_samoa: 'ASM',
|
||||
andorra: 'AND',
|
||||
angola: 'AGO',
|
||||
anguilla: 'AIA',
|
||||
antarctica: 'ATA',
|
||||
antigua_and_barbuda: 'ATG',
|
||||
argentina: 'ARG',
|
||||
armenia: 'ARM',
|
||||
australia: 'AUS',
|
||||
austria: 'AUT',
|
||||
azerbaijan: 'AZE',
|
||||
bahrain: 'BHR',
|
||||
bangladesh: 'BGD',
|
||||
barbados: 'BRB',
|
||||
belarus: 'BLR',
|
||||
belgium: 'BEL',
|
||||
belize: 'BLZ',
|
||||
benin: 'BEN',
|
||||
bermuda: 'BMU',
|
||||
bhutan: 'BTN',
|
||||
bolivia: 'BOL',
|
||||
bosnia_and_herzegovina: 'BIH',
|
||||
botswana: 'BWA',
|
||||
brazil: 'BRA',
|
||||
brunei: 'BRN',
|
||||
bulgaria: 'BGR',
|
||||
burkina_faso: 'BFA',
|
||||
burundi: 'BDI',
|
||||
cambodia: 'KHM',
|
||||
cameroon: 'CMR',
|
||||
canada: 'CAN',
|
||||
cape_verde: 'CPV',
|
||||
central_african_republic: 'CAF',
|
||||
chad: 'TCD',
|
||||
chile: 'CHL',
|
||||
china: 'CHN',
|
||||
colombia: 'COL',
|
||||
comoros: 'COM',
|
||||
cook_islands: 'COK',
|
||||
costa_rica: 'CRI',
|
||||
croatia: 'HRV',
|
||||
cuba: 'CUB',
|
||||
cyprus: 'CYP',
|
||||
czech_republic: 'CZE',
|
||||
democratic_republic_of_the_congo: 'COD',
|
||||
denmark: 'DNK',
|
||||
djibouti: 'DJI',
|
||||
dominica: 'DMA',
|
||||
dominican_republic: 'DOM',
|
||||
ecuador: 'ECU',
|
||||
egypt: 'EGY',
|
||||
el_salvador: 'SLV',
|
||||
equatorial_guinea: 'GNQ',
|
||||
eritrea: 'ERI',
|
||||
estonia: 'EST',
|
||||
ethiopia: 'ETH',
|
||||
fiji: 'FJI',
|
||||
finland: 'FIN',
|
||||
france: 'FRA',
|
||||
french_polynesia: 'PYF',
|
||||
gabon: 'GAB',
|
||||
gambia: 'GMB',
|
||||
germany: 'DEU',
|
||||
ghana: 'GHA',
|
||||
greece: 'GRC',
|
||||
greenland: 'GRL',
|
||||
grenada: 'GRD',
|
||||
guatemala: 'GTM',
|
||||
guinea: 'GIN',
|
||||
guyana: 'GUY',
|
||||
haiti: 'HTI',
|
||||
honduras: 'HND',
|
||||
hungary: 'HUN',
|
||||
iceland: 'ISL',
|
||||
india: 'IND',
|
||||
indonesia: 'IDN',
|
||||
iran: 'IRN',
|
||||
israel: 'ISR',
|
||||
italy: 'ITA',
|
||||
ivory_coast: 'CIV',
|
||||
japan: 'JPN',
|
||||
jordan: 'JOR',
|
||||
kazakhstan: 'KAZ',
|
||||
kenya: 'KEN',
|
||||
korea: 'KOR',
|
||||
kuwait: 'KWT',
|
||||
kyrgyzstan: 'KGZ',
|
||||
laos: 'LAO',
|
||||
latvia: 'LVA',
|
||||
lebanon: 'LBN',
|
||||
lesotho: 'LSO',
|
||||
liberia: 'LBR',
|
||||
libya: 'LBY',
|
||||
liechtenstein: 'LIE',
|
||||
lithuania: 'LTU',
|
||||
luxembourg: 'LUX',
|
||||
macedonia: 'MKD',
|
||||
madagascar: 'MDG',
|
||||
malawi: 'MWI',
|
||||
malaysia: 'MYS',
|
||||
maldives: 'MDV',
|
||||
mali: 'MLI',
|
||||
malta: 'MLT',
|
||||
marshall_islands: 'MHL',
|
||||
mauritania: 'MRT',
|
||||
mauritius: 'MUS',
|
||||
mexico: 'MEX',
|
||||
moldova: 'MDA',
|
||||
mongolia: 'MNG',
|
||||
montenegro: 'MNE',
|
||||
montserrat: 'MSR',
|
||||
morocco: 'MAR',
|
||||
mozambique: 'MOZ',
|
||||
myanmar: 'MMR',
|
||||
namibia: 'NAM',
|
||||
nauru: 'NRU',
|
||||
nepal: 'NPL',
|
||||
netherlands: 'NLD',
|
||||
new_caledonia: 'NCL',
|
||||
new_zealand: 'NZL',
|
||||
nicaragua: 'NIC',
|
||||
niger: 'NER',
|
||||
nigeria: 'NGA',
|
||||
northern_mariana_islands: 'MNP',
|
||||
norway: 'NOR',
|
||||
oman: 'OMN',
|
||||
pakistan: 'PAK',
|
||||
palau: 'PLW',
|
||||
panama: 'PAN',
|
||||
papua_new_guinea: 'PNG',
|
||||
paraguay: 'PRY',
|
||||
peru: 'PER',
|
||||
philippines: 'PHL',
|
||||
poland: 'POL',
|
||||
portugal: 'PRT',
|
||||
qatar: 'QAT',
|
||||
republic_of_serbia: 'SRB',
|
||||
romania: 'ROU',
|
||||
russia: 'RUS',
|
||||
rwanda: 'RWA',
|
||||
saint_lucia: 'LCA',
|
||||
saint_pierre_and_miquelon: 'SPM',
|
||||
saint_vincent_and_the_grenadines: 'VCT',
|
||||
samoa: 'WSM',
|
||||
san_marino: 'SMR',
|
||||
sao_tome_and_principe: 'STP',
|
||||
saudi_arabia: 'SAU',
|
||||
senegal: 'SEN',
|
||||
seychelles: 'SYC',
|
||||
sierra_leone: 'SLE',
|
||||
singapore: 'SGP',
|
||||
slovakia: 'SVK',
|
||||
slovenia: 'SVN',
|
||||
solomon_islands: 'SLB',
|
||||
somalia: 'SOM',
|
||||
south_africa: 'ZAF',
|
||||
spain: 'ESP',
|
||||
sri_lanka: 'LKA',
|
||||
sudan: 'SDN',
|
||||
suriname: 'SUR',
|
||||
sweden: 'SWE',
|
||||
switzerland: 'CHE',
|
||||
syria: 'SYR',
|
||||
taiwan: 'TWN',
|
||||
tajikistan: 'TJK',
|
||||
tanzania: 'TZA',
|
||||
thailand: 'THA',
|
||||
the_bahamas: 'BHS',
|
||||
timorleste: 'TLS',
|
||||
togo: 'TGO',
|
||||
tonga: 'TON',
|
||||
trinidad_and_tobago: 'TTO',
|
||||
tunisia: 'TUN',
|
||||
turkey: 'TUR',
|
||||
turkmenistan: 'TKM',
|
||||
turks_and_caicos_islands: 'TCA',
|
||||
uganda: 'UGA',
|
||||
uk: 'GBR',
|
||||
ukraine: 'UKR',
|
||||
united_arab_emirates: 'ARE',
|
||||
united_states_minor_outlying_islands: 'UMI',
|
||||
united_states_virgin_islands: 'VIR',
|
||||
uruguay: 'URY',
|
||||
usa: 'USA',
|
||||
uzbekistan: 'UZB',
|
||||
vanuatu: 'VUT',
|
||||
venezuela: 'VEN',
|
||||
vietnam: 'VNM',
|
||||
wallis_and_futuna: 'WLF',
|
||||
yemen: 'YEM',
|
||||
zambia: 'ZMB',
|
||||
zimbabwe: 'ZWE',
|
||||
};
|
||||
|
||||
export default function migrateFromLegacy(
|
||||
formData: PartialFormData,
|
||||
): MigrationOutput {
|
||||
const legacy = String(formData.select_country ?? '').toLowerCase();
|
||||
if (!legacy) return {};
|
||||
|
||||
if (LEGACY_TO_COMPOSITE[legacy]) {
|
||||
return {
|
||||
admin_level: '1',
|
||||
composite: LEGACY_TO_COMPOSITE[legacy],
|
||||
worldview: 'ukr',
|
||||
};
|
||||
}
|
||||
if (LEGACY_TO_AGGREGATED[legacy]) {
|
||||
const { country, region_set } = LEGACY_TO_AGGREGATED[legacy];
|
||||
return {
|
||||
admin_level: 'aggregated',
|
||||
country,
|
||||
region_set,
|
||||
worldview: 'ukr',
|
||||
};
|
||||
}
|
||||
if (LEGACY_TO_ISO_A3[legacy]) {
|
||||
return {
|
||||
admin_level: '1',
|
||||
country: LEGACY_TO_ISO_A3[legacy],
|
||||
worldview: 'ukr',
|
||||
};
|
||||
}
|
||||
// Unknown legacy code — leave the country control empty so the user
|
||||
// can re-pick. Worldview defaults flow from the control's own default.
|
||||
return {};
|
||||
}
|
||||
@@ -1,79 +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 {
|
||||
CountryMapChartProps,
|
||||
CountryMapFormData,
|
||||
CountryMapTransformedProps,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Translate Superset's standard ChartProps into the shape the renderer
|
||||
* needs. Notable: derive `geoJsonUrl` from the form data so the renderer
|
||||
* can fetch the right output from the build pipeline.
|
||||
*
|
||||
* URL layout (matches the build script's output naming):
|
||||
* <worldview>_admin0.geo.json — world choropleth, per worldview
|
||||
* ukr_admin1_<adm0_a3>.geo.json — country subdivisions, shared
|
||||
* regional_<adm0_a3>_<set>_ukr.geo.json — aggregated regions, shared
|
||||
* composite_<id>_ukr.geo.json — composite maps, shared
|
||||
*
|
||||
* Worldview only affects Admin 0 because Natural Earth's Admin 1 layer is
|
||||
* a single global file with no worldview variants — subdivisions within a
|
||||
* country don't change with the user's worldview choice. Aggregated
|
||||
* regions and composites dissolve / regroup Admin 1, so they inherit the
|
||||
* shared baseline too. The "_ukr" suffix on shared outputs is historical
|
||||
* and kept for back-compat with the build pipeline's naming.
|
||||
*/
|
||||
const GEOJSON_BASE = '/static/assets/country-maps';
|
||||
const SHARED_ADMIN1_WORLDVIEW = 'ukr';
|
||||
|
||||
export default function transformProps(
|
||||
chartProps: CountryMapChartProps,
|
||||
): CountryMapTransformedProps {
|
||||
const { queriesData, width, height } = chartProps;
|
||||
// ChartProps.formData is camelCase-normalized; use rawFormData to keep
|
||||
// the snake_case keys defined in CountryMapFormData / the control panel.
|
||||
const formData = chartProps.rawFormData as CountryMapFormData;
|
||||
const data = (queriesData?.[0]?.data as Record<string, unknown>[]) ?? [];
|
||||
|
||||
const worldview = formData.worldview || 'ukr';
|
||||
const adminLevel = formData.admin_level ?? 0;
|
||||
|
||||
let geoJsonUrl: string | null = null;
|
||||
if (formData.composite) {
|
||||
geoJsonUrl = `${GEOJSON_BASE}/composite_${formData.composite}_${SHARED_ADMIN1_WORLDVIEW}.geo.json`;
|
||||
} else if (formData.region_set && formData.country) {
|
||||
geoJsonUrl = `${GEOJSON_BASE}/regional_${formData.country}_${formData.region_set}_${SHARED_ADMIN1_WORLDVIEW}.geo.json`;
|
||||
} else if (adminLevel === 1 && formData.country) {
|
||||
geoJsonUrl = `${GEOJSON_BASE}/${SHARED_ADMIN1_WORLDVIEW}_admin1_${formData.country}.geo.json`;
|
||||
} else if (adminLevel === 0) {
|
||||
geoJsonUrl = `${GEOJSON_BASE}/${worldview}_admin0.geo.json`;
|
||||
}
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
formData,
|
||||
data,
|
||||
geoJsonUrl,
|
||||
metricName: typeof formData.metric === 'string' ? formData.metric : null,
|
||||
numberFormat: formData.number_format,
|
||||
linearColorScheme: formData.linear_color_scheme,
|
||||
};
|
||||
}
|
||||
@@ -1,85 +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 { ChartProps, QueryFormData } from '@superset-ui/core';
|
||||
|
||||
/** Admin levels supported by the plugin. */
|
||||
export type AdminLevel = 0 | 1;
|
||||
|
||||
/**
|
||||
* Identifier of one of the per-region-set dissolved outputs (e.g. `nuts_1`
|
||||
* for Türkiye, `regions` for France/Italy/Philippines), shipped from the
|
||||
* build pipeline's `regional_aggregations.yaml`.
|
||||
*/
|
||||
export type RegionSetId = string;
|
||||
|
||||
/**
|
||||
* Form data shape for the new country map. All fields are optional except
|
||||
* the standard `viz_type`/`datasource` etc. inherited from QueryFormData.
|
||||
*/
|
||||
export interface CountryMapFormData extends QueryFormData {
|
||||
/** NE worldview code (e.g. `ukr`, `default`, `ind`). Defaults to repo-configured value. */
|
||||
worldview?: string;
|
||||
|
||||
/** 0 = countries, 1 = subdivisions (or aggregated regions when region_set is set). */
|
||||
admin_level?: AdminLevel;
|
||||
|
||||
/** ISO_A3 country code; required when admin_level === 1 (and not a composite). */
|
||||
country?: string;
|
||||
|
||||
/** Identifier from regional_aggregations.yaml; selects an aggregated region layer. */
|
||||
region_set?: RegionSetId;
|
||||
|
||||
/** Identifier from composite_maps.yaml; selects a composite map (e.g. france_overseas). */
|
||||
composite?: string;
|
||||
|
||||
/** ISO codes to keep; if non-empty, filter rendered features to these. */
|
||||
region_includes?: string[];
|
||||
/** ISO codes to drop; mutually exclusive with the above in normal use. */
|
||||
region_excludes?: string[];
|
||||
|
||||
/** When true (default), repositioned flying islands are visible; when false, they are dropped. */
|
||||
show_flying_islands?: boolean;
|
||||
|
||||
/** NE NAME_<lang> field code (e.g. `en`, `fr`, `de`, `vi`). */
|
||||
name_language?: string;
|
||||
|
||||
// ---- Inherited / shared ---- //
|
||||
/** Chosen metric to color the choropleth by. */
|
||||
metric?: string;
|
||||
/** Color scheme name from @superset-ui/core. */
|
||||
linear_color_scheme?: string;
|
||||
/** Number-format string for tooltip values. */
|
||||
number_format?: string;
|
||||
}
|
||||
|
||||
/** Props shape passed to the renderer after transformProps. */
|
||||
export interface CountryMapTransformedProps {
|
||||
width: number;
|
||||
height: number;
|
||||
formData: CountryMapFormData;
|
||||
data: Array<Record<string, unknown>>;
|
||||
// The resolved GeoJSON URL the renderer should fetch — derived from
|
||||
// formData fields by transformProps; null if unsatisfiable.
|
||||
geoJsonUrl: string | null;
|
||||
metricName: string | null;
|
||||
numberFormat: string | undefined;
|
||||
linearColorScheme: string | undefined;
|
||||
}
|
||||
|
||||
export type CountryMapChartProps = ChartProps<CountryMapFormData>;
|
||||
@@ -1,50 +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 buildQuery from '../../src/plugin/buildQuery';
|
||||
import { CountryMapFormData } from '../../src/types';
|
||||
|
||||
const baseFormData: CountryMapFormData = {
|
||||
datasource: '3__table',
|
||||
viz_type: 'country_map',
|
||||
metric: 'sum__num',
|
||||
entity: 'iso_code',
|
||||
adhoc_filters: [],
|
||||
};
|
||||
|
||||
test('buildQuery returns a chart/data context with one query', () => {
|
||||
const ctx = buildQuery(baseFormData);
|
||||
expect(ctx).toBeDefined();
|
||||
expect(Array.isArray(ctx.queries)).toBe(true);
|
||||
expect(ctx.queries.length).toBe(1);
|
||||
});
|
||||
|
||||
test('buildQuery preserves form data on the context', () => {
|
||||
const ctx = buildQuery(baseFormData);
|
||||
expect(ctx.form_data).toMatchObject({
|
||||
viz_type: 'country_map',
|
||||
metric: 'sum__num',
|
||||
entity: 'iso_code',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildQuery normalizes orderby on the query object', () => {
|
||||
const ctx = buildQuery({ ...baseFormData, order_desc: true });
|
||||
// orderby should be present as an array (possibly empty if no metric ordering)
|
||||
expect(Array.isArray(ctx.queries[0].orderby)).toBe(true);
|
||||
});
|
||||
@@ -1,214 +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 controlPanel from '../../src/plugin/controlPanel';
|
||||
|
||||
const allControlNames = (): string[] => {
|
||||
const names: string[] = [];
|
||||
controlPanel.controlPanelSections.forEach(section => {
|
||||
if (!section || !section.controlSetRows) return;
|
||||
section.controlSetRows.forEach(row => {
|
||||
row.forEach(cell => {
|
||||
if (typeof cell === 'string') {
|
||||
names.push(cell);
|
||||
} else if (cell && typeof cell === 'object' && 'name' in cell) {
|
||||
names.push((cell as { name: string }).name);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return names;
|
||||
};
|
||||
|
||||
const findControl = (name: string) => {
|
||||
for (const section of controlPanel.controlPanelSections) {
|
||||
if (!section || !section.controlSetRows) continue;
|
||||
for (const row of section.controlSetRows) {
|
||||
for (const cell of row) {
|
||||
if (
|
||||
cell &&
|
||||
typeof cell === 'object' &&
|
||||
'name' in cell &&
|
||||
(cell as { name: string }).name === name
|
||||
) {
|
||||
return (cell as { config: any }).config;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
test('all required new controls are present', () => {
|
||||
const names = allControlNames();
|
||||
// The full set of controls the redesign introduced. `show_flying_islands`
|
||||
// is intentionally absent — it's a no-op until the build pipeline tags
|
||||
// features as flying, and showing a dead toggle was misleading.
|
||||
for (const required of [
|
||||
'worldview',
|
||||
'admin_level',
|
||||
'country',
|
||||
'region_set',
|
||||
'composite',
|
||||
'region_includes',
|
||||
'region_excludes',
|
||||
'name_language',
|
||||
]) {
|
||||
expect(names).toContain(required);
|
||||
}
|
||||
});
|
||||
|
||||
test('show_flying_islands is intentionally absent (no-op until features are tagged)', () => {
|
||||
const names = allControlNames();
|
||||
expect(names).not.toContain('show_flying_islands');
|
||||
});
|
||||
|
||||
test('worldview defaults to ukr (Superset editorial choice)', () => {
|
||||
const c = findControl('worldview');
|
||||
expect(c).not.toBeNull();
|
||||
expect(c.default).toBe('ukr');
|
||||
});
|
||||
|
||||
test('name_language defaults to en', () => {
|
||||
const c = findControl('name_language');
|
||||
expect(c).not.toBeNull();
|
||||
expect(c.default).toBe('en');
|
||||
});
|
||||
|
||||
test('admin_level offers exactly 0 / 1 / aggregated', () => {
|
||||
const c = findControl('admin_level');
|
||||
expect(c).not.toBeNull();
|
||||
const codes = c.choices.map((ch: [string, string]) => ch[0]);
|
||||
expect(codes).toEqual(['0', '1', 'aggregated']);
|
||||
});
|
||||
|
||||
test('worldview includes ukr', () => {
|
||||
const c = findControl('worldview');
|
||||
const codes = c.choices.map((ch: [string, string]) => ch[0]);
|
||||
expect(codes).toContain('ukr');
|
||||
});
|
||||
|
||||
test('worldview offers multiple NE-published editorials', () => {
|
||||
const c = findControl('worldview');
|
||||
const codes = c.choices.map((ch: [string, string]) => ch[0]);
|
||||
// Sanity-check a handful of high-impact ones — we don't pin the full
|
||||
// set so the build pipeline can add/remove worldviews without
|
||||
// tripping this test.
|
||||
expect(codes).toEqual(expect.arrayContaining(['default', 'ukr']));
|
||||
expect(codes.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('country selector visibility hides on Admin 0', () => {
|
||||
const c = findControl('country');
|
||||
expect(c).not.toBeNull();
|
||||
// Admin 0 (number 0 OR string "0") AND no composite → hidden
|
||||
expect(c.visibility({ controls: { admin_level: { value: 0 } } })).toBe(false);
|
||||
expect(c.visibility({ controls: { admin_level: { value: '0' } } })).toBe(
|
||||
false,
|
||||
);
|
||||
// Admin 1 → visible
|
||||
expect(c.visibility({ controls: { admin_level: { value: '1' } } })).toBe(
|
||||
true,
|
||||
);
|
||||
// Composite set → hidden regardless of admin_level
|
||||
expect(
|
||||
c.visibility({
|
||||
controls: {
|
||||
admin_level: { value: '1' },
|
||||
composite: { value: 'france_overseas' },
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('country validator only fires when country is actually needed', () => {
|
||||
const c = findControl('country');
|
||||
// Admin 0 → no validator (the Data tab would otherwise show a
|
||||
// permanent "Country: cannot be empty" badge for a hidden control).
|
||||
expect(
|
||||
c.mapStateToProps({ controls: { admin_level: { value: '0' } } }).validators,
|
||||
).toEqual([]);
|
||||
// Composite set → no validator
|
||||
expect(
|
||||
c.mapStateToProps({
|
||||
controls: {
|
||||
admin_level: { value: '1' },
|
||||
composite: { value: 'france_overseas' },
|
||||
},
|
||||
}).validators,
|
||||
).toEqual([]);
|
||||
// Admin 1, no composite → validator present (single non-empty validator)
|
||||
expect(
|
||||
c.mapStateToProps({ controls: { admin_level: { value: '1' } } }).validators
|
||||
.length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
test('composite selector hides on Admin 0', () => {
|
||||
const c = findControl('composite');
|
||||
expect(c.visibility({ controls: { admin_level: { value: '0' } } })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(c.visibility({ controls: { admin_level: { value: '1' } } })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('region_set selector only visible when admin_level === aggregated', () => {
|
||||
const c = findControl('region_set');
|
||||
expect(c).not.toBeNull();
|
||||
expect(
|
||||
c.visibility({ controls: { admin_level: { value: 'aggregated' } } }),
|
||||
).toBe(true);
|
||||
expect(c.visibility({ controls: { admin_level: { value: '1' } } })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(c.visibility({ controls: { admin_level: { value: '0' } } })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('region_set choices key off the selected country (via mapStateToProps)', () => {
|
||||
const c = findControl('region_set');
|
||||
// SelectControl expects `choices` to be a literal array, so we feed
|
||||
// them through mapStateToProps which receives the current control
|
||||
// state on every render.
|
||||
const turChoices = c.mapStateToProps({
|
||||
controls: { country: { value: 'TUR' } },
|
||||
}).choices;
|
||||
expect(turChoices.length).toBeGreaterThanOrEqual(1);
|
||||
expect(turChoices[0][0]).toBe('nuts_1');
|
||||
|
||||
const fraChoices = c.mapStateToProps({
|
||||
controls: { country: { value: 'FRA' } },
|
||||
}).choices;
|
||||
expect(fraChoices.length).toBeGreaterThanOrEqual(1);
|
||||
expect(fraChoices[0][0]).toBe('regions');
|
||||
|
||||
// Country with no aggregated regions defined → empty
|
||||
const usaChoices = c.mapStateToProps({
|
||||
controls: { country: { value: 'USA' } },
|
||||
}).choices;
|
||||
expect(usaChoices).toEqual([]);
|
||||
});
|
||||
|
||||
test('composite selector includes france_overseas', () => {
|
||||
const c = findControl('composite');
|
||||
const codes = c.choices.map((ch: [string, string]) => ch[0]);
|
||||
expect(codes).toContain('france_overseas');
|
||||
});
|
||||
@@ -1,120 +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 migrateFromLegacy from '../../src/plugin/migrateFromLegacy';
|
||||
|
||||
test('legacy "france" → Country view with FRA pre-selected', () => {
|
||||
expect(migrateFromLegacy({ select_country: 'france' })).toEqual({
|
||||
admin_level: '1',
|
||||
country: 'FRA',
|
||||
worldview: 'ukr',
|
||||
});
|
||||
});
|
||||
|
||||
test('legacy "usa" → Country view with USA pre-selected', () => {
|
||||
expect(migrateFromLegacy({ select_country: 'usa' })).toEqual({
|
||||
admin_level: '1',
|
||||
country: 'USA',
|
||||
worldview: 'ukr',
|
||||
});
|
||||
});
|
||||
|
||||
test('legacy "uk" maps to GBR (the ISO 3166 code)', () => {
|
||||
expect(migrateFromLegacy({ select_country: 'uk' })).toEqual({
|
||||
admin_level: '1',
|
||||
country: 'GBR',
|
||||
worldview: 'ukr',
|
||||
});
|
||||
});
|
||||
|
||||
test('legacy "france_overseas" maps to composite, not country', () => {
|
||||
expect(migrateFromLegacy({ select_country: 'france_overseas' })).toEqual({
|
||||
admin_level: '1',
|
||||
composite: 'france_overseas',
|
||||
worldview: 'ukr',
|
||||
});
|
||||
});
|
||||
|
||||
test('legacy "france_regions" maps to aggregated regions for France', () => {
|
||||
expect(migrateFromLegacy({ select_country: 'france_regions' })).toEqual({
|
||||
admin_level: 'aggregated',
|
||||
country: 'FRA',
|
||||
region_set: 'regions',
|
||||
worldview: 'ukr',
|
||||
});
|
||||
});
|
||||
|
||||
test('legacy "turkey_regions" maps to TUR / nuts_1', () => {
|
||||
expect(migrateFromLegacy({ select_country: 'turkey_regions' })).toEqual({
|
||||
admin_level: 'aggregated',
|
||||
country: 'TUR',
|
||||
region_set: 'nuts_1',
|
||||
worldview: 'ukr',
|
||||
});
|
||||
});
|
||||
|
||||
test('legacy "italy_regions" maps to ITA / regions', () => {
|
||||
expect(migrateFromLegacy({ select_country: 'italy_regions' })).toEqual({
|
||||
admin_level: 'aggregated',
|
||||
country: 'ITA',
|
||||
region_set: 'regions',
|
||||
worldview: 'ukr',
|
||||
});
|
||||
});
|
||||
|
||||
test('legacy "philippines_regions" maps to PHL / regions', () => {
|
||||
expect(migrateFromLegacy({ select_country: 'philippines_regions' })).toEqual({
|
||||
admin_level: 'aggregated',
|
||||
country: 'PHL',
|
||||
region_set: 'regions',
|
||||
worldview: 'ukr',
|
||||
});
|
||||
});
|
||||
|
||||
test('uppercase / mixed case legacy values still match', () => {
|
||||
expect(migrateFromLegacy({ select_country: 'France' })).toEqual({
|
||||
admin_level: '1',
|
||||
country: 'FRA',
|
||||
worldview: 'ukr',
|
||||
});
|
||||
});
|
||||
|
||||
test('unknown legacy code → empty migration (user re-picks)', () => {
|
||||
expect(migrateFromLegacy({ select_country: 'atlantis' })).toEqual({});
|
||||
});
|
||||
|
||||
test('missing select_country → empty migration', () => {
|
||||
expect(migrateFromLegacy({})).toEqual({});
|
||||
});
|
||||
|
||||
test('every legacy "_regions" key resolves to an existing region_set', () => {
|
||||
// Smoke-check that the four legacy aggregated keys all map to
|
||||
// (country, region_set) pairs the build pipeline actually emits.
|
||||
const cases = [
|
||||
'france_regions',
|
||||
'italy_regions',
|
||||
'philippines_regions',
|
||||
'turkey_regions',
|
||||
];
|
||||
cases.forEach(name => {
|
||||
const m = migrateFromLegacy({ select_country: name });
|
||||
expect(m.admin_level).toBe('aggregated');
|
||||
expect(typeof m.country).toBe('string');
|
||||
expect(typeof m.region_set).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -1,142 +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 { ChartProps } from '@superset-ui/core';
|
||||
import transformProps from '../../src/plugin/transformProps';
|
||||
import { CountryMapChartProps, CountryMapFormData } from '../../src/types';
|
||||
|
||||
const baseFormData: CountryMapFormData = {
|
||||
datasource: '3__table',
|
||||
viz_type: 'country_map',
|
||||
};
|
||||
|
||||
const buildChartProps = (
|
||||
formData: Partial<CountryMapFormData>,
|
||||
data: Record<string, unknown>[] = [],
|
||||
): CountryMapChartProps =>
|
||||
new ChartProps({
|
||||
formData: { ...baseFormData, ...formData },
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [{ data }],
|
||||
} as any) as CountryMapChartProps;
|
||||
|
||||
test('Admin 0 (no country) → world choropleth URL', () => {
|
||||
const out = transformProps(
|
||||
buildChartProps({ admin_level: 0, worldview: 'ukr' }),
|
||||
);
|
||||
expect(out.geoJsonUrl).toBe(
|
||||
'/static/assets/country-maps/ukr_admin0.geo.json',
|
||||
);
|
||||
});
|
||||
|
||||
test('Admin 1 + country → per-country file URL (worldview-agnostic)', () => {
|
||||
const out = transformProps(
|
||||
buildChartProps({ admin_level: 1, country: 'FRA', worldview: 'ukr' }),
|
||||
);
|
||||
expect(out.geoJsonUrl).toBe(
|
||||
'/static/assets/country-maps/ukr_admin1_FRA.geo.json',
|
||||
);
|
||||
});
|
||||
|
||||
test('Admin 1 stays on the shared (ukr) file regardless of worldview', () => {
|
||||
const out = transformProps(
|
||||
buildChartProps({ admin_level: 1, country: 'FRA', worldview: 'chn' }),
|
||||
);
|
||||
expect(out.geoJsonUrl).toBe(
|
||||
'/static/assets/country-maps/ukr_admin1_FRA.geo.json',
|
||||
);
|
||||
});
|
||||
|
||||
test('Region set + country → regional aggregation URL (shared)', () => {
|
||||
const out = transformProps(
|
||||
buildChartProps({
|
||||
admin_level: 1,
|
||||
country: 'TUR',
|
||||
region_set: 'nuts_1',
|
||||
worldview: 'rus', // exotic worldview — regional URL still resolves to ukr
|
||||
}),
|
||||
);
|
||||
expect(out.geoJsonUrl).toBe(
|
||||
'/static/assets/country-maps/regional_TUR_nuts_1_ukr.geo.json',
|
||||
);
|
||||
});
|
||||
|
||||
test('Composite overrides admin_level + country (shared across worldviews)', () => {
|
||||
const out = transformProps(
|
||||
buildChartProps({
|
||||
admin_level: 1,
|
||||
country: 'FRA',
|
||||
composite: 'france_overseas',
|
||||
worldview: 'chn',
|
||||
}),
|
||||
);
|
||||
expect(out.geoJsonUrl).toBe(
|
||||
'/static/assets/country-maps/composite_france_overseas_ukr.geo.json',
|
||||
);
|
||||
});
|
||||
|
||||
test('Worldview defaults to ukr when not specified', () => {
|
||||
const out = transformProps(buildChartProps({ admin_level: 0 }));
|
||||
expect(out.geoJsonUrl).toBe(
|
||||
'/static/assets/country-maps/ukr_admin0.geo.json',
|
||||
);
|
||||
});
|
||||
|
||||
test('Different worldview reflected in URL', () => {
|
||||
const out = transformProps(
|
||||
buildChartProps({ admin_level: 0, worldview: 'default' }),
|
||||
);
|
||||
expect(out.geoJsonUrl).toBe(
|
||||
'/static/assets/country-maps/default_admin0.geo.json',
|
||||
);
|
||||
});
|
||||
|
||||
test('Admin 1 without country → no URL (chart UI should prompt)', () => {
|
||||
const out = transformProps(buildChartProps({ admin_level: 1 }));
|
||||
expect(out.geoJsonUrl).toBeNull();
|
||||
});
|
||||
|
||||
test('Passes through metricName, numberFormat, linearColorScheme', () => {
|
||||
const out = transformProps(
|
||||
buildChartProps({
|
||||
admin_level: 0,
|
||||
metric: 'sum__num',
|
||||
number_format: 'SMART_NUMBER',
|
||||
linear_color_scheme: 'schemeBlues',
|
||||
}),
|
||||
);
|
||||
expect(out.metricName).toBe('sum__num');
|
||||
expect(out.numberFormat).toBe('SMART_NUMBER');
|
||||
expect(out.linearColorScheme).toBe('schemeBlues');
|
||||
});
|
||||
|
||||
test('Passes through query data rows', () => {
|
||||
const data = [
|
||||
{ iso: 'FR-75C', sum__num: 100 },
|
||||
{ iso: 'FR-971', sum__num: 50 },
|
||||
];
|
||||
const out = transformProps(buildChartProps({ admin_level: 0 }, data));
|
||||
expect(out.data).toEqual(data);
|
||||
});
|
||||
|
||||
test('Passes through width/height', () => {
|
||||
const out = transformProps(buildChartProps({ admin_level: 0 }));
|
||||
expect(out.width).toBe(800);
|
||||
expect(out.height).toBe(600);
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "../..",
|
||||
"outDir": "lib",
|
||||
"rootDir": "src",
|
||||
"declarationDir": "lib",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.json", "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" }
|
||||
]
|
||||
}
|
||||
@@ -1,30 +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 '*.geojson' {
|
||||
const value: GeoJSON.FeatureCollection;
|
||||
export default value;
|
||||
}
|
||||
@@ -56,7 +56,6 @@ jest.mock('@superset-ui/chart-controls', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
BRAND_COLOR: '#00A699',
|
||||
GenericDataType: { Temporal: 2, String: 1 },
|
||||
extractTimegrain: jest.fn(() => 'P1D'),
|
||||
getMetricLabel: jest.fn(metric => metric),
|
||||
@@ -281,30 +280,4 @@ describe('BigNumberWithTrendline transformProps', () => {
|
||||
expect(result.bigNumber).toBe(360);
|
||||
expect(result.subheader).toBe('50.0% WoW');
|
||||
});
|
||||
|
||||
test('should not crash and should return undefined mainColor when colorPicker is null', () => {
|
||||
const chartProps = {
|
||||
width: 400,
|
||||
height: 300,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ __timestamp: 1, value: 100 },
|
||||
] as unknown as BigNumberDatum[],
|
||||
colnames: ['__timestamp', 'value'],
|
||||
coltypes: ['TEMPORAL', 'NUMERIC'],
|
||||
},
|
||||
],
|
||||
formData: { ...baseFormData, colorPicker: null },
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
theme: { colors: { grayscale: { light5: '#eee' } } },
|
||||
};
|
||||
|
||||
const result = transformProps(
|
||||
chartProps as unknown as BigNumberWithTrendlineChartProps,
|
||||
);
|
||||
expect(result.mainColor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
BRAND_COLOR,
|
||||
extractTimegrain,
|
||||
getNumberFormatter,
|
||||
NumberFormats,
|
||||
@@ -141,9 +140,8 @@ export default function transformProps(
|
||||
const compareLag = Number(compareLag_) || 0;
|
||||
let formattedSubheader = subheader;
|
||||
|
||||
const mainColor = colorPicker
|
||||
? `rgb(${colorPicker.r}, ${colorPicker.g}, ${colorPicker.b})`
|
||||
: undefined;
|
||||
const { r, g, b } = colorPicker;
|
||||
const mainColor = `rgb(${r}, ${g}, ${b})`;
|
||||
|
||||
const xAxisLabel = getXAxisLabel(rawFormData) as string;
|
||||
let trendLineData: TimeSeriesDatum[] | undefined;
|
||||
@@ -292,12 +290,12 @@ export default function transformProps(
|
||||
symbol: 'circle',
|
||||
symbolSize: 10,
|
||||
showSymbol: false,
|
||||
color: mainColor ?? BRAND_COLOR,
|
||||
color: mainColor,
|
||||
areaStyle: {
|
||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: mainColor ?? BRAND_COLOR,
|
||||
color: mainColor,
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
|
||||
@@ -285,6 +285,8 @@ export default function transformProps(
|
||||
}
|
||||
const labelProps = {
|
||||
color: theme.colorText,
|
||||
textBorderColor: theme.colorBgBase,
|
||||
textBorderWidth: 1,
|
||||
};
|
||||
const traverse = (
|
||||
treeNodes: TreeNode[],
|
||||
|
||||
@@ -389,9 +389,6 @@ export function transformSeries(
|
||||
...(colorByPrimaryAxis ? {} : { itemStyle }),
|
||||
// @ts-ignore
|
||||
type: plotType,
|
||||
// Cap bar width so a single data point doesn't stretch across the
|
||||
// entire chart area. Bars with many categories auto-size below this cap.
|
||||
...(plotType === 'bar' ? { barMaxWidth: 100 } : {}),
|
||||
smooth: seriesType === 'smooth',
|
||||
triggerLineEvent: true,
|
||||
// @ts-expect-error
|
||||
|
||||
@@ -1,53 +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 { ChartProps } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import { EchartsSunburstChartProps } from '../../src/Sunburst/types';
|
||||
import transformProps from '../../src/Sunburst/transformProps';
|
||||
|
||||
const formData = {
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
groupby: ['category'],
|
||||
metric: 'sum__value',
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
formData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ category: 'A', sum__value: 10 },
|
||||
{ category: 'B', sum__value: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
theme: supersetTheme,
|
||||
});
|
||||
|
||||
test('series label has no textBorderColor or textBorderWidth', () => {
|
||||
const { echartOptions } = transformProps(
|
||||
chartProps as EchartsSunburstChartProps,
|
||||
);
|
||||
const series = (echartOptions as any).series[0];
|
||||
expect(series.label).not.toHaveProperty('textBorderColor');
|
||||
expect(series.label).not.toHaveProperty('textBorderWidth');
|
||||
});
|
||||
@@ -17,14 +17,16 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { memo } from 'react';
|
||||
import { PureComponent } from 'react';
|
||||
import { TableRenderer } from './TableRenderers';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
type PivotTableProps = ComponentProps<typeof TableRenderer>;
|
||||
|
||||
function PivotTable(props: PivotTableProps) {
|
||||
return <TableRenderer {...props} />;
|
||||
class PivotTable extends PureComponent<PivotTableProps> {
|
||||
render() {
|
||||
return <TableRenderer {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default memo(PivotTable);
|
||||
export default PivotTable;
|
||||
|
||||