You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@superset.apache.org by "liang-wenjie (via GitHub)" <gi...@apache.org> on 2026/09/10 09:36:42 UTC

[PR] feat(plugin-chart-echarts): add Big Number with YoY & MoM chart plugin [superset]

liang-wenjie opened a new pull request, #44140:
URL: https://github.com/apache/superset/pull/44140

   ## Summary
   
   Add a new ECharts chart type "Big Number with YoY & MoM" that renders a KPI card with an optional title, a big number, and MoM/YoY comparison rows (reference: "Big Number with Trendline").
   
   - Two mutually exclusive comparison sources per slot: time shift (percentage change) or comparison value metric (column / custom SQL rendered as the computed value with direction arrows)
   - Time column (X axis) + time grain controls; adhoc SQL time columns supported
   - Open time ranges use point-to-point comparison (missing points render as "—")
   - 5-tier ratio font sizes; configurable positions/gaps (blank = 0); auto-spacing against overlap
   - Big number number/currency format independent from MoM/YoY formats
   - Chinese translations and light/dark thumbnails
   
   Closes #44139.
   
   ## BEFORE / AFTER
   
   Adds a new chart type; existing charts are unaffected.
   
   ## TESTING INSTRUCTIONS
   
   1. Create a chart with the new "带同比与环比的大数字" (Big Number with YoY & MoM) viz type.
   2. Configure a main metric and optionally a title.
   3. For each comparison slot, either:
      - choose a time shift (e.g. "1 month ago" / "1 year ago") to show the percentage change, or
      - pick a comparison value metric (column or custom SQL) to render the value directly with an up/down arrow.
   4. With a time column set and no time range ("No filter"), the comparison matches the previous period point-to-point; missing points render as "—".
   5. Verify layout controls: 5-tier font sizes, gaps from title / big number, comparison spacing, swap MoM and YoY positions, per-slot percent difference formats.
   
   ## ADDITIONAL INFORMATION
   
   - Has associated issue: #44139
   - Changes UI: yes
   - Introduces new feature: yes
   - Python backend unchanged


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat(plugin-chart-echarts): add Big Number with YoY & MoM chart plugin [superset]

Posted by "netlify[bot] (via GitHub)" <gi...@apache.org>.
netlify[bot] commented on PR #44140:
URL: https://github.com/apache/superset/pull/44140#issuecomment-5616474371

   ### <span aria-hidden="true">✅</span> Deploy Preview for *superset-docs-preview* ready!
   
   
   |  Name | Link |
   |:-:|------------------------|
   |<span aria-hidden="true">🔨</span> Latest commit | 7545bdb9489a515e0b8729901a38a4b3fa97435b |
   |<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/projects/superset-docs-preview/deploys/6aa27a2bae63790008a85391 |
   |<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-44140--superset-docs-preview.netlify.app](https://deploy-preview-44140--superset-docs-preview.netlify.app) |
   |<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTQ0MTQwLS1zdXBlcnNldC1kb2NzLXByZXZpZXcubmV0bGlmeS5hcHAifQ.OeqTgT0wkawJd6lqAU9vbGdK4SO2O4miDDGUK4giNR0)<br /><br />_Use your smartphone camera to open QR code link._</details> |
   |<span aria-hidden="true">🤖</span> Make changes | [Run an agent on this branch](https://app.netlify.com/projects/superset-docs-preview/agent-runs#DctLCoAgEADQu8y6yIy-N-gUMub0gSbFNJDo7rl7m_eC85ZdgAmgAO3xWvZsaeipVsJQ6WNTV2RNXiWbFFsuo7uDJ-QcDLnTptnk0iHKHqVG6pp-FEIMOLTNWMP3Aw) |
   ---
   <!-- [superset-docs-preview Preview](https://deploy-preview-44140--superset-docs-preview.netlify.app) -->
   _To edit notification comments on pull requests, go to your [Netlify project configuration](https://app.netlify.com/projects/superset-docs-preview/configuration/notifications#deploy-notifications)._


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat(plugin-chart-echarts): add Big Number with YoY & MoM chart plugin [superset]

Posted by "rusackas (via GitHub)" <gi...@apache.org>.
rusackas commented on PR #44140:
URL: https://github.com/apache/superset/pull/44140#issuecomment-5647597930

   @liang-wenjie thanks for tackling this! 
   
   Couple of things before it can land: 
   * the bito thread on `timeRange.ts`'s `addMonths` looks right, going back a month from a month-end date (like the 31st) overflows into the wrong day, which'll throw off the point-to-point comparisons.
   * pre-commit's failing on hardcoded hex colors in `transformProps.ts` (should be theme tokens) and unit-tests never finished, so this isn't mergeable as-is.
   
   Say the word if you want a hand, we can push some commits. 


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat(plugin-chart-echarts): add Big Number with YoY & MoM chart plugin [superset]

Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #44140:
URL: https://github.com/apache/superset/pull/44140#discussion_r3977956409


##########
superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberYoyMom/timeRange.ts:
##########
@@ -0,0 +1,290 @@
+/**
+ * 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.
+ */
+/**
+ * Expands open-ended time range strings into fully-enclosed
+ * "start : end" timestamps so the backend's Time Comparison validation
+ * (superset/models/helpers.py) accepts the query.
+ *
+ * The backend (superset/utils/date_parser.py) already resolves "Last ...",
+ * "Next ...", "Current ..." and "previous calendar ..." ranges into enclosed
+ * bounds on its own, so those are passed through untouched. Ranges such as
+ * "Previous week", "This month", "Yesterday" or "month to date" resolve to an
+ * open-ended range (only an "until" bound) on the backend, which fails the
+ * enclosed-range check that is required whenever a time comparison
+ * (time_offsets) is present. Those are expanded here using the same calendar
+ * semantics the backend applies to their capitalized counterparts.
+ */
+
+const DATE_SEPARATOR = ' : ';
+
+// Ranges the backend resolves to enclosed bounds on its own.
+const BACKEND_ENCLOSED =
+  /^(Last|Next|Current)( \d+)? (second|minute|hour|day|week|month|quarter|year)s?$|^previous calendar (week|month|quarter|year)$/;
+
+const SECOND = 1000;
+const MINUTE = 60 * SECOND;
+const HOUR = 60 * MINUTE;
+const DAY = 24 * HOUR;
+const WEEK = 7 * DAY;
+
+const pad = (n: number): string => String(n).padStart(2, '0');
+
+const formatDateTime = (d: Date): string =>
+  `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
+    d.getHours(),
+  )}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+
+const truncateToDay = (d: Date): Date =>
+  new Date(d.getFullYear(), d.getMonth(), d.getDate());
+
+const startOfIsoWeek = (d: Date): Date => {
+  const day = truncateToDay(d);
+  const offset = (day.getDay() + 6) % 7; // Monday = 0
+  return new Date(day.getFullYear(), day.getMonth(), day.getDate() - offset);
+};
+
+const addMonths = (d: Date, months: number): Date =>
+  new Date(d.getFullYear(), d.getMonth() + months, d.getDate());

Review Comment:
   <div>
   
   
   <div id="suggestion">
   <div id="issue"><b>Month-end date overflow</b></div>
   <div id="fix">
   
   `addMonths` overflows when the source day exceeds the target month's length: `Mar 31 - 1 month` yields `Mar 3`, not `Feb 28`. This is reachable via lowercase `last/next N months/quarters` in `toEnclosedTimeRange` (base = `today`, day can be 31), producing a wrong time range. Note `shiftPointToOffset` (lines 198-205) already clamps correctly, so the two paths diverge. Clamp to the target month's last day.
   </div>
   
   
   <details>
   <summary>
   <b>Code suggestion</b>
   </summary>
   <blockquote>Check the AI-generated fix before applying</blockquote>
   <div id="code">
   
   
   ````suggestion
   const addMonths = (d: Date, months: number): Date => {
     const target = new Date(d.getFullYear(), d.getMonth() + months, 1);
     const lastDay = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
     return new Date(target.getFullYear(), target.getMonth(), Math.min(d.getDate(), lastDay));
   };
   ````
   
   </div>
   </details>
   
   
   
   </div>
   
   
   
   
   <small><i>Code Review Run #ba0d8a</i></small>
   </div>
   
   ---
   Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
   - [ ] Yes, avoid them



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat(plugin-chart-echarts): add Big Number with YoY & MoM chart plugin [superset]

Posted by "codecov[bot] (via GitHub)" <gi...@apache.org>.
codecov[bot] commented on PR #44140:
URL: https://github.com/apache/superset/pull/44140#issuecomment-5616514641

   ## [Codecov](https://app.codecov.io/gh/apache/superset/pull/44140?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) Report
   :white_check_mark: All modified and coverable lines are covered by tests.
   :white_check_mark: Project coverage is 57.46%. Comparing base ([`e22ce19`](https://app.codecov.io/gh/apache/superset/commit/e22ce197866ded732e4990063ae74697d89d383a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)) to head ([`7545bdb`](https://app.codecov.io/gh/apache/superset/commit/7545bdb9489a515e0b8729901a38a4b3fa97435b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)).
   > :exclamation:  There is a different number of reports uploaded between BASE (e22ce19) and HEAD (7545bdb). Click for more details.
   > 
   > <details><summary>HEAD has 87 uploads less than BASE</summary>
   >
   >| Flag | BASE (e22ce19) | HEAD (7545bdb) |
   >|------|------|------|
   >|python|67|2|
   >|presto|12|1|
   >|hive|12|1|
   ></details>
   
   <details><summary>Additional details and impacted files</summary>
   
   
   
   ```diff
   @@             Coverage Diff             @@
   ##           master   #44140       +/-   ##
   ===========================================
   - Coverage   80.13%   57.46%   -22.67%     
   ===========================================
     Files        2924     2924               
     Lines      172288   172288               
     Branches    39995    39995               
   ===========================================
   - Hits       138060    99013    -39047     
   - Misses      31642    72344    +40702     
   + Partials     2586      931     -1655     
   ```
   
   | [Flag](https://app.codecov.io/gh/apache/superset/pull/44140/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | Coverage Δ | |
   |---|---|---|
   | [hive](https://app.codecov.io/gh/apache/superset/pull/44140/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `37.35% <ø> (ø)` | |
   | [mysql](https://app.codecov.io/gh/apache/superset/pull/44140/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `?` | |
   | [postgres](https://app.codecov.io/gh/apache/superset/pull/44140/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `?` | |
   | [presto](https://app.codecov.io/gh/apache/superset/pull/44140/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `39.24% <ø> (ø)` | |
   | [python](https://app.codecov.io/gh/apache/superset/pull/44140/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `39.29% <ø> (-45.21%)` | :arrow_down: |
   | [sqlite](https://app.codecov.io/gh/apache/superset/pull/44140/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `?` | |
   | [unit](https://app.codecov.io/gh/apache/superset/pull/44140/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `?` | |
   
   Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#carryforward-flags-in-the-pull-request-comment) to find out more.
   </details>
   
   [:umbrella: View full report in Codecov by Harness](https://app.codecov.io/gh/apache/superset/pull/44140?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache).   
   :loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache).
   <details><summary> :rocket: New features to boost your workflow: </summary>
   
   - :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
   - :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
   </details>


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat(plugin-chart-echarts): add Big Number with YoY & MoM chart plugin [superset]

Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on code in PR #44140:
URL: https://github.com/apache/superset/pull/44140#discussion_r4056388165


##########
superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberYoyMom/timeRange.ts:
##########
@@ -0,0 +1,336 @@
+/**
+ * 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.
+ */
+/**
+ * Expands open-ended time range strings into fully-enclosed
+ * "start : end" timestamps so the backend's Time Comparison validation
+ * (superset/models/helpers.py) accepts the query.
+ *
+ * The backend (superset/utils/date_parser.py) already resolves "Last ...",
+ * "Next ...", "Current ..." and "previous calendar ..." ranges into enclosed
+ * bounds on its own, so those are passed through untouched. Ranges such as
+ * "Previous week", "This month", "Yesterday" or "month to date" resolve to an
+ * open-ended range (only an "until" bound) on the backend, which fails the
+ * enclosed-range check that is required whenever a time comparison
+ * (time_offsets) is present. Those are expanded here using the same calendar
+ * semantics the backend applies to their capitalized counterparts.
+ */
+
+const DATE_SEPARATOR = ' : ';
+
+// Ranges the backend resolves to enclosed bounds on its own.
+const BACKEND_ENCLOSED =
+  /^(Last|Next|Current)( \d+)? (second|minute|hour|day|week|month|quarter|year)s?$|^previous calendar (week|month|quarter|year)$/;
+
+const SECOND = 1000;
+const MINUTE = 60 * SECOND;
+const HOUR = 60 * MINUTE;
+const DAY = 24 * HOUR;
+const WEEK = 7 * DAY;
+
+const pad = (n: number): string => String(n).padStart(2, '0');
+
+const formatDateTime = (d: Date): string =>
+  `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
+    d.getHours(),
+  )}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+
+const truncateToDay = (d: Date): Date =>
+  new Date(d.getFullYear(), d.getMonth(), d.getDate());
+
+const startOfIsoWeek = (d: Date): Date => {
+  const day = truncateToDay(d);
+  const offset = (day.getDay() + 6) % 7; // Monday = 0
+  return new Date(day.getFullYear(), day.getMonth(), day.getDate() - offset);
+};
+
+const addMonths = (d: Date, months: number): Date => {
+  const target = new Date(
+    d.getFullYear(),
+    d.getMonth() + months,
+    1,
+    d.getHours(),
+    d.getMinutes(),
+    d.getSeconds(),
+    d.getMilliseconds(),
+  );
+  const lastDay = new Date(
+    target.getFullYear(),
+    target.getMonth() + 1,
+    0,
+  ).getDate();
+  return new Date(
+    target.getFullYear(),
+    target.getMonth(),
+    Math.min(d.getDate(), lastDay),
+    d.getHours(),
+    d.getMinutes(),
+    d.getSeconds(),
+    d.getMilliseconds(),
+  );
+};
+
+const startOfQuarter = (d: Date): Date =>
+  new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1);
+
+const startOfYear = (d: Date): Date => new Date(d.getFullYear(), 0, 1);
+
+const shiftUnits = (d: Date, amount: number, unit: string): Date => {
+  switch (unit) {
+    case 'second':
+      return new Date(d.getTime() + amount * SECOND);
+    case 'minute':
+      return new Date(d.getTime() + amount * MINUTE);
+    case 'hour':
+      return new Date(d.getTime() + amount * HOUR);
+    case 'day':
+      return new Date(d.getTime() + amount * DAY);
+    case 'week':
+      return new Date(d.getTime() + amount * WEEK);
+    case 'month':
+      return addMonths(d, amount);
+    case 'quarter':
+      return addMonths(d, amount * 3);
+    case 'year':
+      return addMonths(d, amount * 12);
+    default:
+      return d;
+  }
+};
+
+/**
+ * Point-to-point comparison helpers for open-ended time ranges ("No filter").
+ *
+ * When a comparison offset is configured without an enclosed time range there
+ * is no "current period" the backend can shift, so the query instead returns
+ * the metric grouped by the time column (newest first). The comparison value
+ * is then matched in the render path by shifting the newest point by the
+ * offset (e.g. "1 month ago") and finding the exact point in the series.
+ * Points that do not exist in the series render as "—".
+ */
+
+const HOUR_MS = 3_600_000;
+
+export type TimeOffset = { amount: number; unit: string };
+
+const OFFSET_RE =
+  /^(\d+)\s+(second|minute|hour|day|week|month|quarter|year)s?\s+ago$/i;
+
+const UNIT_DAYS: Record<string, number> = {
+  second: 1 / 86_400,
+  minute: 1 / 1_440,
+  hour: 1 / 24,
+  day: 1,
+  week: 7,
+  month: 31,
+  quarter: 93,
+  year: 366,
+};
+
+const parseTimeOffset = (offset: string | undefined): TimeOffset | null => {
+  if (!offset) return null;
+  const match = OFFSET_RE.exec(offset.trim());
+  if (!match) return null;
+  return { amount: parseInt(match[1], 10), unit: match[2].toLowerCase() };
+};
+
+/**
+ * Maximum number of series rows needed to cover all configured offsets.
+ * A 1-year day-granularity offset needs ~366 rows; month granularity far less.
+ */
+export const offsetMaxRows = (offsets: (string | undefined)[]): number => {
+  let maxDays = 1;
+  offsets.forEach(offset => {
+    const parsed = parseTimeOffset(offset);
+    if (parsed) {
+      maxDays = Math.max(
+        maxDays,
+        Math.ceil(parsed.amount * (UNIT_DAYS[parsed.unit] ?? 1)),
+      );
+    }
+  });
+  return Math.max(30, Math.min(2_000, maxDays + 1));

Review Comment:
   **Suggestion:** A valid free-form offset beyond 2,000 days is truncated to 2,000 rows, so the requested comparison point can be omitted and displayed as missing.
   
   **Assessment:** 🟠 `Major` · 🔁 `Occurrence: Rarely` · 🏷️ `Off-by-one`
   
   [![Use CodeAnt Skill](https://new-codeant-butcket.s3.us-west-1.amazonaws.com/badges/use-codeant-skill-flat-v2.svg)](https://docs.codeant.ai/cli/resolve-pr-comments-skill) [![Fix in Cursor](https://new-codeant-butcket.s3.us-west-1.amazonaws.com/badges/fix-in-cursor-flat.svg)](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=4deb0c2cee5842c4bb1b8e1fc7337bd0&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [![Fix in VSCode Claude](https://new-codeant-butcket.s3.us-west-1.amazonaws.com/badges/fix-in-vscode-claude-flat.svg)](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=4deb0c2cee5842c4bb1b8e1fc7337bd0&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
   <details>
   <summary><b>Prompt for AI Agent 🤖 </b></summary>
   
   ```mdx
   This is a comment left during a code review.
   
   **Path:** superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberYoyMom/timeRange.ts
   **Line:** 156:167
   **Comment:**
   	*Off By One: A valid free-form offset beyond 2,000 days is truncated to 2,000 rows, so the requested comparison point can be omitted and displayed as missing.
   
   Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
   Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
   ```
   </details>
   <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F44140&comment_hash=e4e608c06028609b1becf64a8e07917865a1a6c956707761d0d8fbd45b5c6d21&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F44140&comment_hash=e4e608c06028609b1becf64a8e07917865a1a6c956707761d0d8fbd45b5c6d21&reaction=dislike'>👎</a>



##########
superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberYoyMom/timeRange.ts:
##########
@@ -0,0 +1,336 @@
+/**
+ * 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.
+ */
+/**
+ * Expands open-ended time range strings into fully-enclosed
+ * "start : end" timestamps so the backend's Time Comparison validation
+ * (superset/models/helpers.py) accepts the query.
+ *
+ * The backend (superset/utils/date_parser.py) already resolves "Last ...",
+ * "Next ...", "Current ..." and "previous calendar ..." ranges into enclosed
+ * bounds on its own, so those are passed through untouched. Ranges such as
+ * "Previous week", "This month", "Yesterday" or "month to date" resolve to an
+ * open-ended range (only an "until" bound) on the backend, which fails the
+ * enclosed-range check that is required whenever a time comparison
+ * (time_offsets) is present. Those are expanded here using the same calendar
+ * semantics the backend applies to their capitalized counterparts.
+ */
+
+const DATE_SEPARATOR = ' : ';
+
+// Ranges the backend resolves to enclosed bounds on its own.
+const BACKEND_ENCLOSED =
+  /^(Last|Next|Current)( \d+)? (second|minute|hour|day|week|month|quarter|year)s?$|^previous calendar (week|month|quarter|year)$/;
+
+const SECOND = 1000;
+const MINUTE = 60 * SECOND;
+const HOUR = 60 * MINUTE;
+const DAY = 24 * HOUR;
+const WEEK = 7 * DAY;
+
+const pad = (n: number): string => String(n).padStart(2, '0');
+
+const formatDateTime = (d: Date): string =>
+  `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
+    d.getHours(),
+  )}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+
+const truncateToDay = (d: Date): Date =>
+  new Date(d.getFullYear(), d.getMonth(), d.getDate());
+
+const startOfIsoWeek = (d: Date): Date => {
+  const day = truncateToDay(d);
+  const offset = (day.getDay() + 6) % 7; // Monday = 0
+  return new Date(day.getFullYear(), day.getMonth(), day.getDate() - offset);
+};
+
+const addMonths = (d: Date, months: number): Date => {
+  const target = new Date(
+    d.getFullYear(),
+    d.getMonth() + months,
+    1,
+    d.getHours(),
+    d.getMinutes(),
+    d.getSeconds(),
+    d.getMilliseconds(),
+  );
+  const lastDay = new Date(
+    target.getFullYear(),
+    target.getMonth() + 1,
+    0,
+  ).getDate();
+  return new Date(
+    target.getFullYear(),
+    target.getMonth(),
+    Math.min(d.getDate(), lastDay),
+    d.getHours(),
+    d.getMinutes(),
+    d.getSeconds(),
+    d.getMilliseconds(),
+  );
+};
+
+const startOfQuarter = (d: Date): Date =>
+  new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1);
+
+const startOfYear = (d: Date): Date => new Date(d.getFullYear(), 0, 1);
+
+const shiftUnits = (d: Date, amount: number, unit: string): Date => {
+  switch (unit) {
+    case 'second':
+      return new Date(d.getTime() + amount * SECOND);
+    case 'minute':
+      return new Date(d.getTime() + amount * MINUTE);
+    case 'hour':
+      return new Date(d.getTime() + amount * HOUR);
+    case 'day':
+      return new Date(d.getTime() + amount * DAY);
+    case 'week':
+      return new Date(d.getTime() + amount * WEEK);
+    case 'month':
+      return addMonths(d, amount);
+    case 'quarter':
+      return addMonths(d, amount * 3);
+    case 'year':
+      return addMonths(d, amount * 12);
+    default:
+      return d;
+  }
+};
+
+/**
+ * Point-to-point comparison helpers for open-ended time ranges ("No filter").
+ *
+ * When a comparison offset is configured without an enclosed time range there
+ * is no "current period" the backend can shift, so the query instead returns
+ * the metric grouped by the time column (newest first). The comparison value
+ * is then matched in the render path by shifting the newest point by the
+ * offset (e.g. "1 month ago") and finding the exact point in the series.
+ * Points that do not exist in the series render as "—".
+ */
+
+const HOUR_MS = 3_600_000;
+
+export type TimeOffset = { amount: number; unit: string };
+
+const OFFSET_RE =
+  /^(\d+)\s+(second|minute|hour|day|week|month|quarter|year)s?\s+ago$/i;
+
+const UNIT_DAYS: Record<string, number> = {
+  second: 1 / 86_400,
+  minute: 1 / 1_440,
+  hour: 1 / 24,
+  day: 1,
+  week: 7,
+  month: 31,
+  quarter: 93,
+  year: 366,
+};
+
+const parseTimeOffset = (offset: string | undefined): TimeOffset | null => {
+  if (!offset) return null;
+  const match = OFFSET_RE.exec(offset.trim());
+  if (!match) return null;
+  return { amount: parseInt(match[1], 10), unit: match[2].toLowerCase() };
+};
+
+/**
+ * Maximum number of series rows needed to cover all configured offsets.
+ * A 1-year day-granularity offset needs ~366 rows; month granularity far less.
+ */
+export const offsetMaxRows = (offsets: (string | undefined)[]): number => {
+  let maxDays = 1;
+  offsets.forEach(offset => {
+    const parsed = parseTimeOffset(offset);
+    if (parsed) {
+      maxDays = Math.max(
+        maxDays,
+        Math.ceil(parsed.amount * (UNIT_DAYS[parsed.unit] ?? 1)),
+      );
+    }
+  });
+  return Math.max(30, Math.min(2_000, maxDays + 1));
+};
+
+/**
+ * Parses a series time value into a UTC Date. Supports ISO-like date strings
+ * ("2026-09-01 00:00:00"), bare year-month values ("202609"), year-month-day
+ * numbers (20260901) and epoch milliseconds (1788220800000).
+ */
+export const parseTimePointValue = (value: unknown): Date | null => {
+  if (value === null || value === undefined || value === '') return null;
+  const numeric =
+    typeof value === 'number' ? value : Number(String(value).trim());
+  // Epoch milliseconds (>= 1e11 ≈ year 1973).
+  if (Number.isFinite(numeric) && Math.abs(numeric) > 1e11) {
+    const date = new Date(numeric);
+    return Number.isNaN(date.getTime()) ? null : date;
+  }
+  const text = String(value).trim();
+  const match = text.match(
+    /^(\d{4})\s*[-/年.]?\s*(\d{1,2})?\s*[-/月.]?\s*(\d{1,2})?\s*[日]?/,
+  );
+  if (!match) return null;
+  const year = parseInt(match[1], 10);
+  const month = match[2] ? parseInt(match[2], 10) : 1;
+  const day = match[3] ? parseInt(match[3], 10) : 1;
+  if (month < 1 || month > 12 || day < 1 || day > 31) return null;
+  const date = new Date(Date.UTC(year, month - 1, day));
+  return Number.isNaN(date.getTime()) ? null : date;
+};
+
+/**
+ * Aligns a date to the configured time grain so points from a full timestamp
+ * column ("2026-09-01 10:30:00") can be matched against shifted points.
+ */
+export const alignToTimeGrain = (
+  date: Date,
+  grain: string | undefined,
+): Date => {
+  if (grain === 'year') return new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
+  if (grain === 'month')
+    return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
+  if (grain === 'week') {
+    const isoOffset = (date.getUTCDay() + 6) % 7; // Monday = 0
+    return new Date(
+      Date.UTC(
+        date.getUTCFullYear(),
+        date.getUTCMonth(),
+        date.getUTCDate() - isoOffset,
+      ),
+    );
+  }
+  return new Date(
+    Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
+  );
+};
+
+/**
+ * Shifts a point backwards by a time offset. Month/quarter/year shifts clamp
+ * to the end of the target month (Jan 31 - 1 month => Jan 31 -> Dec 31).
+ */
+export const shiftPointToOffset = (date: Date, offset: string): Date => {
+  const parsed = parseTimeOffset(offset);
+  if (!parsed) return date;
+  const { amount, unit } = parsed;
+  const year = date.getUTCFullYear();
+  const month = date.getUTCMonth();
+  const day = date.getUTCDate();
+  if (unit === 'second') return new Date(date.getTime() - amount * 1000);
+  if (unit === 'minute') return new Date(date.getTime() - amount * 60_000);
+  if (unit === 'hour') return new Date(date.getTime() - amount * HOUR_MS);
+  if (unit === 'week') return new Date(Date.UTC(year, month, day - amount * 7));
+  if (unit === 'day') return new Date(Date.UTC(year, month, day - amount));
+
+  let targetMonth = month;
+  if (unit === 'year') targetMonth -= amount * 12;
+  else if (unit === 'quarter') targetMonth -= amount * 3;
+  else if (unit === 'month') targetMonth -= amount;
+  const targetYear = year + Math.floor(targetMonth / 12);
+  const normalizedMonth = ((targetMonth % 12) + 12) % 12;
+  const lastDay = new Date(
+    Date.UTC(targetYear, normalizedMonth + 1, 0),
+  ).getUTCDate();
+  return new Date(
+    Date.UTC(targetYear, normalizedMonth, Math.min(day, lastDay)),
+  );
+};
+
+export const toEnclosedTimeRange = (
+  timeRange: string | undefined,
+  now: Date = new Date(),
+): string | undefined => {
+  if (!timeRange || timeRange === 'No filter') {
+    return timeRange;
+  }
+  if (timeRange.includes(DATE_SEPARATOR)) {
+    return timeRange;
+  }
+  if (BACKEND_ENCLOSED.test(timeRange)) {
+    return timeRange;
+  }
+
+  const enclosed = (since: Date, until: Date): string =>
+    `${formatDateTime(since)}${DATE_SEPARATOR}${formatDateTime(until)}`;
+
+  const normalized = timeRange.trim().toLowerCase();
+  const today = truncateToDay(now);
+  const startWeek = startOfIsoWeek(now);
+  const startMonth = new Date(now.getFullYear(), now.getMonth(), 1);
+  const startQuarter = startOfQuarter(now);
+  const startYear = startOfYear(now);
+
+  const expanded: [Date, Date] | undefined = (() => {
+    switch (normalized) {
+      case 'yesterday':
+        return [new Date(today.getTime() - DAY), today];
+      case 'today':
+        return [today, new Date(today.getTime() + DAY)];
+      case 'previous week':
+        return [new Date(startWeek.getTime() - WEEK), startWeek];
+      case 'previous month':
+        return [addMonths(startMonth, -1), startMonth];
+      case 'previous quarter':

Review Comment:
   **Suggestion:** These ranges use the browser's local timezone, but the backend resolves time ranges in its configured timezone, producing different comparison periods when timezones differ.
   
   **Assessment:** 🟠 `Major` · 🔁 `Occurrence: Sometimes` · 🏷️ `Possible bug`
   
   [![Use CodeAnt Skill](https://new-codeant-butcket.s3.us-west-1.amazonaws.com/badges/use-codeant-skill-flat-v2.svg)](https://docs.codeant.ai/cli/resolve-pr-comments-skill) [![Fix in Cursor](https://new-codeant-butcket.s3.us-west-1.amazonaws.com/badges/fix-in-cursor-flat.svg)](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=3104325a7ccd4a44847a36e17813faf4&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [![Fix in VSCode Claude](https://new-codeant-butcket.s3.us-west-1.amazonaws.com/badges/fix-in-vscode-claude-flat.svg)](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=3104325a7ccd4a44847a36e17813faf4&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
   <details>
   <summary><b>Prompt for AI Agent 🤖 </b></summary>
   
   ```mdx
   This is a comment left during a code review.
   
   **Path:** superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberYoyMom/timeRange.ts
   **Line:** 285:288
   **Comment:**
   	*Possible Bug: These ranges use the browser's local timezone, but the backend resolves time ranges in its configured timezone, producing different comparison periods when timezones differ.
   
   Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
   Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
   ```
   </details>
   <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F44140&comment_hash=072d5609cb4a4f8738ac37be1975aab8070060191bb7e107cd799fe921f01782&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F44140&comment_hash=072d5609cb4a4f8738ac37be1975aab8070060191bb7e107cd799fe921f01782&reaction=dislike'>👎</a>



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat(plugin-chart-echarts): add Big Number with YoY & MoM chart plugin [superset]

Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #44140:
URL: https://github.com/apache/superset/pull/44140#issuecomment-5616448658

   AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat(plugin-chart-echarts): add Big Number with YoY & MoM chart plugin [superset]

Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #44140:
URL: https://github.com/apache/superset/pull/44140#issuecomment-5749172502

   AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
   
   <details>
   <summary>
   Bito Usage Guide
   </summary>
   
   **Commands**
         
   Type the following command in the pull request comment and save the comment.
         
   - `/review` - Manually triggers an incremental AI Review.
   - `/review full` - Manually triggers a full AI Review.
   
   - `/pause` - Pauses automatic reviews on this pull request.
   - `/resume` - Resumes automatic reviews.
   - `/resolve` - Marks all Bito-posted review comments as resolved.
   - `/abort` - Cancels all in-progress reviews.
   
         
   Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
         
   **Configuration**
   
   This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
         
   **Documentation & Help**
   - <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
   - <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
   - <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
   - <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
   
   </details>
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat(plugin-chart-echarts): add Big Number with YoY & MoM chart plugin [superset]

Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on PR #44140:
URL: https://github.com/apache/superset/pull/44140#issuecomment-6487421544

   ## <a href="https://codeant.ai/" title="CodeAnt AI"><img src="https://new-codeant-butcket.s3.us-west-1.amazonaws.com/badges/codeant-block-purple-v2.svg" width="28" height="28" align="absmiddle" style="vertical-align:middle" alt="CodeAnt" /></a>&nbsp; CodeAnt PR Risk
   
   **🟡&nbsp; Medium Risk**
   
   - The PR needs attention before merging.
   - Negative current values against a zero baseline get a positive 100% change, and numeric strings bypass the configured number formatter.
   - Distinct comparison metrics sharing a label collapse in the query, so both slots can read the same surviving result.
   - When only YoY is shown, its position includes its own estimated width, which can create a large gap or push it off-tile.
   - Free-form shifts beyond 2,000 days are capped, and browser-local range expansion can disagree with the server timezone.
   
   **Assessed commit:** `63ca039b0c14`


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat(plugin-chart-echarts): add Big Number with YoY & MoM chart plugin [superset]

Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on code in PR #44140:
URL: https://github.com/apache/superset/pull/44140#discussion_r4056388989


##########
superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberYoyMom/transformProps.ts:
##########
@@ -0,0 +1,612 @@
+/**
+ * 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 {
+  getMetricLabel,
+  getNumberFormatter,
+  getValueFormatter,
+  Metric,
+  NumberFormats,
+  QueryFormMetric,
+} from '@superset-ui/core';
+import type { EChartsCoreOption } from 'echarts/core';
+import type { GraphicComponentOption } from 'echarts/components';
+import {
+  DEFAULT_BIG_NUMBER_COLOR,
+  DEFAULT_BIG_NUMBER_FONT_SIZE,
+  DEFAULT_BIG_NUMBER_LEFT,
+  DEFAULT_COMPARISON1_LABEL,
+  DEFAULT_COMPARISON1_OFFSET,
+  DEFAULT_COMPARISON2_LABEL,
+  DEFAULT_COMPARISON_GAP,
+  DEFAULT_COMPARISON2_OFFSET,
+  DEFAULT_COMPARISON_FONT_SIZE,
+  DEFAULT_COMPARISON_NEGATIVE_COLOR,
+  DEFAULT_COMPARISON_POSITIVE_COLOR,
+  DEFAULT_COMPARISON_ZERO_COLOR,
+  DEFAULT_TITLE_COLOR,
+  DEFAULT_TITLE_FONT_SIZE,
+  DEFAULT_TITLE_LEFT,
+} from './constants';
+import {
+  BigNumberYoyMomChartProps,
+  BigNumberYoyMomProps,
+  RGBColor,
+} from './types';
+import {
+  alignToTimeGrain,
+  parseTimePointValue,
+  shiftPointToOffset,
+} from './timeRange';
+
+const toCssColor = (color?: RGBColor, fallback?: string): string =>
+  color ? `rgb(${color.r}, ${color.g}, ${color.b})` : (fallback ?? '');
+
+const toNumber = (value: unknown): number | null => {
+  if (value === null || value === undefined) return null;
+  if (typeof value === 'bigint') return Number(value);
+  const parsed = Number(value);
+  return Number.isNaN(parsed) ? null : parsed;
+};
+
+/**
+ * 'comparison1Left' -> 'comparison1_left'. Form data keys follow the
+ * snake_case control names Explore saves; callers sometimes pass camelCase.
+ */
+const camelToSnake = (key: string): string =>
+  key.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
+
+/**
+ * Normalize a position/gap control value. Any non-numeric input (blank
+ * string, null, NaN) is treated as 0, so clearing the control or typing
+ * invalid characters produces the same layout as an explicit 0.
+ */
+const normalizePosition = (value: unknown): number => {
+  const parsed = Number(value);
+  return Number.isNaN(parsed) ? 0 : parsed;
+};
+
+/**
+ * Read a comparison value column from a result row. Saved metrics are plain
+ * column names; adhoc metrics are named by their label, or by the SQL
+ * expression itself when no label is set. The backend may normalize
+ * whitespace in expression column names, so fall back to a
+ * whitespace-insensitive match before giving up.
+ */
+const readMetricValue = (
+  row: Record<string, unknown>,
+  column: QueryFormMetric,
+): number | string | null | undefined => {
+  const asScalar = (value: unknown): number | string | null | undefined =>
+    typeof value === 'number' || typeof value === 'string'
+      ? value
+      : value == null
+        ? null
+        : undefined;
+  if (typeof column === 'string') return asScalar(row[column]);
+  const sqlExpression =
+    column.expressionType === 'SQL' ? column.sqlExpression : undefined;
+  const candidates = [column.label, sqlExpression].filter(
+    (key): key is string => !!key,
+  );
+  for (const key of candidates) {
+    if (row[key] !== undefined) return asScalar(row[key]);
+  }

Review Comment:
   **Suggestion:** Duplicate comparison labels are resolved by label first, so distinct SQL metrics sharing a label render the surviving metric's value instead of their configured expression.
   
   **Assessment:** 🟠 `Major` · 🔁 `Occurrence: Sometimes` · 🏷️ `Api mismatch`
   
   [![Use CodeAnt Skill](https://new-codeant-butcket.s3.us-west-1.amazonaws.com/badges/use-codeant-skill-flat-v2.svg)](https://docs.codeant.ai/cli/resolve-pr-comments-skill) [![Fix in Cursor](https://new-codeant-butcket.s3.us-west-1.amazonaws.com/badges/fix-in-cursor-flat.svg)](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=b6ba9e1164fa40c4b49257967024a602&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [![Fix in VSCode Claude](https://new-codeant-butcket.s3.us-west-1.amazonaws.com/badges/fix-in-vscode-claude-flat.svg)](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=b6ba9e1164fa40c4b49257967024a602&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
   <details>
   <summary><b>Prompt for AI Agent 🤖 </b></summary>
   
   ```mdx
   This is a comment left during a code review.
   
   **Path:** superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberYoyMom/transformProps.ts
   **Line:** 105:110
   **Comment:**
   	*Api Mismatch: Duplicate comparison labels are resolved by label first, so distinct SQL metrics sharing a label render the surviving metric's value instead of their configured expression.
   
   Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
   Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
   ```
   </details>
   <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F44140&comment_hash=30ac2860727831b3602f3a97da5bfe03a6e2330c511bba2d52f63041d3c60bd4&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F44140&comment_hash=30ac2860727831b3602f3a97da5bfe03a6e2330c511bba2d52f63041d3c60bd4&reaction=dislike'>👎</a>



##########
superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberYoyMom/transformProps.ts:
##########
@@ -0,0 +1,612 @@
+/**
+ * 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 {
+  getMetricLabel,
+  getNumberFormatter,
+  getValueFormatter,
+  Metric,
+  NumberFormats,
+  QueryFormMetric,
+} from '@superset-ui/core';
+import type { EChartsCoreOption } from 'echarts/core';
+import type { GraphicComponentOption } from 'echarts/components';
+import {
+  DEFAULT_BIG_NUMBER_COLOR,
+  DEFAULT_BIG_NUMBER_FONT_SIZE,
+  DEFAULT_BIG_NUMBER_LEFT,
+  DEFAULT_COMPARISON1_LABEL,
+  DEFAULT_COMPARISON1_OFFSET,
+  DEFAULT_COMPARISON2_LABEL,
+  DEFAULT_COMPARISON_GAP,
+  DEFAULT_COMPARISON2_OFFSET,
+  DEFAULT_COMPARISON_FONT_SIZE,
+  DEFAULT_COMPARISON_NEGATIVE_COLOR,
+  DEFAULT_COMPARISON_POSITIVE_COLOR,
+  DEFAULT_COMPARISON_ZERO_COLOR,
+  DEFAULT_TITLE_COLOR,
+  DEFAULT_TITLE_FONT_SIZE,
+  DEFAULT_TITLE_LEFT,
+} from './constants';
+import {
+  BigNumberYoyMomChartProps,
+  BigNumberYoyMomProps,
+  RGBColor,
+} from './types';
+import {
+  alignToTimeGrain,
+  parseTimePointValue,
+  shiftPointToOffset,
+} from './timeRange';
+
+const toCssColor = (color?: RGBColor, fallback?: string): string =>
+  color ? `rgb(${color.r}, ${color.g}, ${color.b})` : (fallback ?? '');
+
+const toNumber = (value: unknown): number | null => {
+  if (value === null || value === undefined) return null;
+  if (typeof value === 'bigint') return Number(value);
+  const parsed = Number(value);
+  return Number.isNaN(parsed) ? null : parsed;
+};
+
+/**
+ * 'comparison1Left' -> 'comparison1_left'. Form data keys follow the
+ * snake_case control names Explore saves; callers sometimes pass camelCase.
+ */
+const camelToSnake = (key: string): string =>
+  key.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
+
+/**
+ * Normalize a position/gap control value. Any non-numeric input (blank
+ * string, null, NaN) is treated as 0, so clearing the control or typing
+ * invalid characters produces the same layout as an explicit 0.
+ */
+const normalizePosition = (value: unknown): number => {
+  const parsed = Number(value);
+  return Number.isNaN(parsed) ? 0 : parsed;
+};
+
+/**
+ * Read a comparison value column from a result row. Saved metrics are plain
+ * column names; adhoc metrics are named by their label, or by the SQL
+ * expression itself when no label is set. The backend may normalize
+ * whitespace in expression column names, so fall back to a
+ * whitespace-insensitive match before giving up.
+ */
+const readMetricValue = (
+  row: Record<string, unknown>,
+  column: QueryFormMetric,
+): number | string | null | undefined => {
+  const asScalar = (value: unknown): number | string | null | undefined =>
+    typeof value === 'number' || typeof value === 'string'
+      ? value
+      : value == null
+        ? null
+        : undefined;
+  if (typeof column === 'string') return asScalar(row[column]);
+  const sqlExpression =
+    column.expressionType === 'SQL' ? column.sqlExpression : undefined;
+  const candidates = [column.label, sqlExpression].filter(
+    (key): key is string => !!key,
+  );
+  for (const key of candidates) {
+    if (row[key] !== undefined) return asScalar(row[key]);
+  }
+  if (sqlExpression) {
+    const expression = sqlExpression.replace(/\s+/g, '');
+    const match = Object.keys(row).find(
+      key => key.replace(/\s+/g, '') === expression,
+    );
+    if (match) return asScalar(row[match]);
+  }
+  return undefined;
+};
+
+/**
+ * Percentage change between the current value and a comparison value.
+ * Mirrors the Big Number with Time Period Comparison behavior: no data on
+ * either side yields null (rendered as "—"), both zero yields 0%, and a zero
+ * denominator yields +/-100%.
+ */
+const computePercent = (
+  current: number | null,
+  previous: number | null,
+): number | null => {
+  if (current === null || previous === null) return null;
+  if (!current && !previous) return 0;
+  if (!previous) return current ? 1 : -1;

Review Comment:
   **Suggestion:** A negative current value with a zero comparison is treated as a positive 100% change, so the arrow and color are reversed.
   
   **Assessment:** 🟠 `Major` · 🔁 `Occurrence: Sometimes` · 🏷️ `Incorrect condition logic`
   
   [![Use CodeAnt Skill](https://new-codeant-butcket.s3.us-west-1.amazonaws.com/badges/use-codeant-skill-flat-v2.svg)](https://docs.codeant.ai/cli/resolve-pr-comments-skill) [![Fix in Cursor](https://new-codeant-butcket.s3.us-west-1.amazonaws.com/badges/fix-in-cursor-flat.svg)](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=b9a406ba6220484f89f99c6da98f3006&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [![Fix in VSCode Claude](https://new-codeant-butcket.s3.us-west-1.amazonaws.com/badges/fix-in-vscode-claude-flat.svg)](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=b9a406ba6220484f89f99c6da98f3006&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
   <details>
   <summary><b>Prompt for AI Agent 🤖 </b></summary>
   
   ```mdx
   This is a comment left during a code review.
   
   **Path:** superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberYoyMom/transformProps.ts
   **Line:** 133:133
   **Comment:**
   	*Incorrect Condition Logic: A negative current value with a zero comparison is treated as a positive 100% change, so the arrow and color are reversed.
   
   Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
   Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
   ```
   </details>
   <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F44140&comment_hash=bdd0d0ca4e226c50995fc3808c4b07c54a44b3ae54d3f478242dfd6ae5cafb62&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F44140&comment_hash=bdd0d0ca4e226c50995fc3808c4b07c54a44b3ae54d3f478242dfd6ae5cafb62&reaction=dislike'>👎</a>



##########
superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberYoyMom/transformProps.ts:
##########
@@ -0,0 +1,612 @@
+/**
+ * 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 {
+  getMetricLabel,
+  getNumberFormatter,
+  getValueFormatter,
+  Metric,
+  NumberFormats,
+  QueryFormMetric,
+} from '@superset-ui/core';
+import type { EChartsCoreOption } from 'echarts/core';
+import type { GraphicComponentOption } from 'echarts/components';
+import {
+  DEFAULT_BIG_NUMBER_COLOR,
+  DEFAULT_BIG_NUMBER_FONT_SIZE,
+  DEFAULT_BIG_NUMBER_LEFT,
+  DEFAULT_COMPARISON1_LABEL,
+  DEFAULT_COMPARISON1_OFFSET,
+  DEFAULT_COMPARISON2_LABEL,
+  DEFAULT_COMPARISON_GAP,
+  DEFAULT_COMPARISON2_OFFSET,
+  DEFAULT_COMPARISON_FONT_SIZE,
+  DEFAULT_COMPARISON_NEGATIVE_COLOR,
+  DEFAULT_COMPARISON_POSITIVE_COLOR,
+  DEFAULT_COMPARISON_ZERO_COLOR,
+  DEFAULT_TITLE_COLOR,
+  DEFAULT_TITLE_FONT_SIZE,
+  DEFAULT_TITLE_LEFT,
+} from './constants';
+import {
+  BigNumberYoyMomChartProps,
+  BigNumberYoyMomProps,
+  RGBColor,
+} from './types';
+import {
+  alignToTimeGrain,
+  parseTimePointValue,
+  shiftPointToOffset,
+} from './timeRange';
+
+const toCssColor = (color?: RGBColor, fallback?: string): string =>
+  color ? `rgb(${color.r}, ${color.g}, ${color.b})` : (fallback ?? '');
+
+const toNumber = (value: unknown): number | null => {
+  if (value === null || value === undefined) return null;
+  if (typeof value === 'bigint') return Number(value);
+  const parsed = Number(value);
+  return Number.isNaN(parsed) ? null : parsed;
+};
+
+/**
+ * 'comparison1Left' -> 'comparison1_left'. Form data keys follow the
+ * snake_case control names Explore saves; callers sometimes pass camelCase.
+ */
+const camelToSnake = (key: string): string =>
+  key.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
+
+/**
+ * Normalize a position/gap control value. Any non-numeric input (blank
+ * string, null, NaN) is treated as 0, so clearing the control or typing
+ * invalid characters produces the same layout as an explicit 0.
+ */
+const normalizePosition = (value: unknown): number => {
+  const parsed = Number(value);
+  return Number.isNaN(parsed) ? 0 : parsed;
+};
+
+/**
+ * Read a comparison value column from a result row. Saved metrics are plain
+ * column names; adhoc metrics are named by their label, or by the SQL
+ * expression itself when no label is set. The backend may normalize
+ * whitespace in expression column names, so fall back to a
+ * whitespace-insensitive match before giving up.
+ */
+const readMetricValue = (
+  row: Record<string, unknown>,
+  column: QueryFormMetric,
+): number | string | null | undefined => {
+  const asScalar = (value: unknown): number | string | null | undefined =>
+    typeof value === 'number' || typeof value === 'string'
+      ? value
+      : value == null
+        ? null
+        : undefined;
+  if (typeof column === 'string') return asScalar(row[column]);
+  const sqlExpression =
+    column.expressionType === 'SQL' ? column.sqlExpression : undefined;
+  const candidates = [column.label, sqlExpression].filter(
+    (key): key is string => !!key,
+  );
+  for (const key of candidates) {
+    if (row[key] !== undefined) return asScalar(row[key]);
+  }
+  if (sqlExpression) {
+    const expression = sqlExpression.replace(/\s+/g, '');
+    const match = Object.keys(row).find(
+      key => key.replace(/\s+/g, '') === expression,
+    );
+    if (match) return asScalar(row[match]);
+  }
+  return undefined;
+};
+
+/**
+ * Percentage change between the current value and a comparison value.
+ * Mirrors the Big Number with Time Period Comparison behavior: no data on
+ * either side yields null (rendered as "—"), both zero yields 0%, and a zero
+ * denominator yields +/-100%.
+ */
+const computePercent = (
+  current: number | null,
+  previous: number | null,
+): number | null => {
+  if (current === null || previous === null) return null;
+  if (!current && !previous) return 0;
+  if (!previous) return current ? 1 : -1;
+  return (current - previous) / Math.abs(previous);
+};
+
+type ComparisonSlot = {
+  show: boolean;
+  label: string;
+  offset?: string;
+  column?: QueryFormMetric;
+  mode?: 'time_shift' | 'metric';
+  current: number | null;
+  comparisonValue: number | string | null | undefined;
+  formatter: (value: number) => string;
+  valueFormatter: (value: number) => string;
+};
+
+// A comparison slot reads its value either from a configured comparison
+// metric (dataset metric or custom SQL expression) or from the backend's
+// time-offset column `<metric label>__<offset>`. The mode mirrors buildQuery:
+// an unset mode falls back to the metric when a comparison column exists.
+const isMetricMode = (
+  mode: 'time_shift' | 'metric' | undefined,
+  column: QueryFormMetric | undefined,
+): boolean => mode === 'metric' || (!mode && !!column);
+
+export default function transformProps(
+  chartProps: BigNumberYoyMomChartProps,
+): BigNumberYoyMomProps {
+  const {
+    width,
+    height,
+    queriesData,
+    formData,
+    theme,
+    datasource: {
+      currencyFormats = {},
+      columnFormats = {},
+      currencyCodeColumn,
+    },
+  } = chartProps;
+  // Form data keys follow the snake_case control names that Explore saves.
+  // Tolerate camelCase too (older form data / direct callers) by falling
+  // back to the snake_case spelling when the camelCase key is absent.
+  const formDataRecord = formData as unknown as Record<string, unknown>;
+  const pick = <T>(key: string): T | undefined =>
+    (formDataRecord[key] ?? formDataRecord[camelToSnake(key)]) as T | undefined;
+  const metric = pick<string>('metric') ?? 'value';
+  const yAxisFormat = pick<string>('yAxisFormat');
+  const currencyFormat =
+    pick<Parameters<typeof getValueFormatter>[4]>('currencyFormat');
+  const headerText = pick<string>('headerText') ?? '';
+  const titleFontSize =
+    pick<number>('titleFontSize') ?? DEFAULT_TITLE_FONT_SIZE;
+  const titleColor = pick<RGBColor>('titleColor');
+  const titleLeft = pick<number>('titleLeft') ?? DEFAULT_TITLE_LEFT;
+  const titleTop = pick<number>('titleTop');
+  const bigNumberFontSize =
+    pick<number>('bigNumberFontSize') ?? DEFAULT_BIG_NUMBER_FONT_SIZE;
+  const bigNumberColor = pick<RGBColor>('bigNumberColor');
+  const bigNumberLeft =
+    pick<number>('bigNumberLeft') ?? DEFAULT_BIG_NUMBER_LEFT;
+  const bigNumberTop = pick<number>('bigNumberTop');
+  const showComparison1 = pick<boolean>('showComparison1') ?? true;
+  const comparison1Label =
+    pick<string>('comparison1Label') ?? t(DEFAULT_COMPARISON1_LABEL);
+  const comparison1Offset =
+    pick<string>('comparison1Offset') ?? DEFAULT_COMPARISON1_OFFSET;
+  const comparison1Column = pick<QueryFormMetric>('comparison1Column');
+  const comparison1PercentDifferenceFormat = pick<string>(
+    'comparison1PercentDifferenceFormat',
+  );
+  const legacyComparison1Left = pick<number>('comparison1Left');
+  const comparison1Mode = pick<'time_shift' | 'metric'>('comparison1Mode');
+  const showComparison2 = pick<boolean>('showComparison2') ?? true;
+  const comparison2Label =
+    pick<string>('comparison2Label') ?? t(DEFAULT_COMPARISON2_LABEL);
+  const comparison2Offset =
+    pick<string>('comparison2Offset') ?? DEFAULT_COMPARISON2_OFFSET;
+  const comparison2Column = pick<QueryFormMetric>('comparison2Column');
+  const comparison2PercentDifferenceFormat = pick<string>(
+    'comparison2PercentDifferenceFormat',
+  );
+  const legacyComparison2Left = pick<number>('comparison2Left');
+  const comparison2Mode = pick<'time_shift' | 'metric'>('comparison2Mode');
+  const comparisonGap = pick<number>('comparisonGap') ?? DEFAULT_COMPARISON_GAP;
+  const swapComparisonOrder = pick<boolean>('swapComparisonOrder') ?? false;
+  const comparisonFontSize =
+    pick<number>('comparisonFontSize') ?? DEFAULT_COMPARISON_FONT_SIZE;
+  const comparisonTop = pick<number>('comparisonTop');
+  const comparisonPositiveColor = pick<RGBColor>('comparisonPositiveColor');
+  const comparisonNegativeColor = pick<RGBColor>('comparisonNegativeColor');
+  const comparisonZeroColor = pick<RGBColor>('comparisonZeroColor');
+  const percentDifferenceFormat =
+    pick<string>('percentDifferenceFormat') ?? NumberFormats.PERCENT_2_POINT;
+  const timeGrainSqla = pick<string>('timeGrainSqla');
+
+  const defaultTitleColor =
+    theme?.colorTextSecondary ?? toCssColor(DEFAULT_TITLE_COLOR);
+  const defaultBigNumberColor =
+    theme?.colorText ?? toCssColor(DEFAULT_BIG_NUMBER_COLOR);
+  const defaultComparisonPositiveColor =
+    theme?.colorSuccess ?? toCssColor(DEFAULT_COMPARISON_POSITIVE_COLOR);
+  const defaultComparisonNegativeColor =
+    theme?.colorError ?? toCssColor(DEFAULT_COMPARISON_NEGATIVE_COLOR);
+  const defaultComparisonZeroColor =
+    theme?.colorTextTertiary ?? toCssColor(DEFAULT_COMPARISON_ZERO_COLOR);
+
+  const { data = [], detected_currency: detectedCurrency } =
+    queriesData[0] || {};
+  const hasData = data.length > 0;
+  const metricName = getMetricLabel(metric);
+
+  // Point-to-point comparison: buildQuery returns a second query with the
+  // metric grouped by the time column (newest first) when a time shift is
+  // configured without an enclosed time range ("No filter"). The comparison
+  // value for an offset is the series point that matches the newest point
+  // shifted by that offset; points that do not exist render as "—".
+  const seriesQuery = queriesData[1];
+  const seriesData = seriesQuery?.data ?? [];
+  const seriesTimeColumn = seriesQuery?.colnames?.[0];
+  const pointSeries = hasData && seriesData.length > 0 ? seriesData : null;
+  const seriesPoints = pointSeries
+    ? (pointSeries
+        .map(row => {
+          const time = seriesTimeColumn
+            ? parseTimePointValue(row[seriesTimeColumn])
+            : null;
+          return time ? { time, value: toNumber(row[metricName]) } : null;
+        })
+        .filter(
+          (point): point is { time: Date; value: number | null } =>
+            point !== null,
+        ) ?? [])
+    : [];
+  const latestPoint = seriesPoints.reduce(
+    (latest, point) =>
+      latest === null || point.time.getTime() > latest.time.getTime()
+        ? point
+        : latest,
+    null as { time: Date; value: number | null } | null,
+  );
+  const matchPointOffset = (offset: string | undefined): number | null => {
+    if (!offset || !latestPoint) return null;
+    const target = alignToTimeGrain(
+      shiftPointToOffset(latestPoint.time, offset),
+      timeGrainSqla,
+    );
+    const hit = seriesPoints.find(
+      point =>
+        alignToTimeGrain(point.time, timeGrainSqla).getTime() ===
+        target.getTime(),
+    );
+    return hit ? hit.value : null;
+  };
+
+  const metricEntry: Metric | undefined = chartProps.datasource?.metrics?.find(
+    metricItem => metricItem.metric_name === metric,
+  );
+
+  const numberFormatter = getValueFormatter(
+    metric,
+    currencyFormats,
+    columnFormats,
+    metricEntry?.d3format || yAxisFormat,
+    currencyFormat,
+    undefined,
+    data,
+    currencyCodeColumn,
+    detectedCurrency,
+  );
+  // MoM and YoY each carry their own percent difference format, falling back
+  // to the legacy global control for charts saved before the split.
+  // d3 percentage formats (e.g. ",.2%") scale the value by 100 and append the
+  // "%" sign themselves; plain number formats (",.2f", Smart Number) expect
+  // the already-scaled percentage value, so multiply by 100 before formatting.
+  const formatPercentValue = (
+    format: string | undefined,
+    percent: number,
+  ): string => {
+    const d3Format = format || percentDifferenceFormat;
+    const formatter = getNumberFormatter(d3Format);
+    return formatter(d3Format.includes('%') ? percent : percent * 100);
+  };
+  const percentFormatter1 = (value: number) =>
+    formatPercentValue(comparison1PercentDifferenceFormat, value);
+  const percentFormatter2 = (value: number) =>
+    formatPercentValue(comparison2PercentDifferenceFormat, value);
+  // Comparison value metrics render with the slot's configured number format
+  // (the same control used for percent differences), falling back to the
+  // default Smart Number format when unset.
+  const valueFormatter1 = (value: number) =>
+    getNumberFormatter(comparison1PercentDifferenceFormat)(value);
+  const valueFormatter2 = (value: number) =>
+    getNumberFormatter(comparison2PercentDifferenceFormat)(value);
+
+  const row = data[0] || {};
+  const rawBigNumber = hasData ? row[metricName] : null;
+  const current = toNumber(rawBigNumber);
+
+  const graphic: GraphicComponentOption[] = [];
+
+  // Auto-positioning keeps the text stack compact and centered, matching the
+  // flex-centered layout used by the other Big Number visualizations.
+  // The title font size follows the shared Subtitle Font Size control:
+  // ratios (<= 1, Tiny 0.125 .. Huge 0.4) are multiplied by the chart
+  // height; legacy numeric values > 1 are absolute pixels.
+  const hasTitle = !!headerText;
+  const titleFontSizeValue =
+    typeof titleFontSize === 'number'
+      ? titleFontSize
+      : Number(titleFontSize) || DEFAULT_TITLE_FONT_SIZE;
+  const titleFontSizePx =
+    titleFontSizeValue <= 1
+      ? Math.ceil(titleFontSizeValue * height)
+      : Math.ceil(titleFontSizeValue);
+  const bigNumberFontSizeValue =
+    typeof bigNumberFontSize === 'number'
+      ? bigNumberFontSize
+      : Number(bigNumberFontSize) || DEFAULT_BIG_NUMBER_FONT_SIZE;
+  const bigNumberFontSizePx =
+    bigNumberFontSizeValue <= 1
+      ? Math.ceil(bigNumberFontSizeValue * height)
+      : Math.ceil(bigNumberFontSizeValue);
+  const comparisonFontSizeValue =
+    typeof comparisonFontSize === 'number'
+      ? comparisonFontSize
+      : Number(comparisonFontSize) || DEFAULT_COMPARISON_FONT_SIZE;
+  const comparisonFontSizePx =
+    comparisonFontSizeValue <= 1
+      ? Math.ceil(comparisonFontSizeValue * height)
+      : Math.ceil(comparisonFontSizeValue);
+  const titleRowHeight = titleFontSizePx * 1.2;
+  const bigNumberRowHeight = bigNumberFontSizePx * 1.2;
+  const comparisonRowHeight = comparisonFontSizePx * 1.2;
+  const titleTopValue = normalizePosition(titleTop);
+  const bigNumberTopValue = normalizePosition(bigNumberTop);
+  const comparisonTopValue = normalizePosition(comparisonTop);
+  const hasComparison =
+    (showComparison1 && !!(comparison1Offset || comparison1Column)) ||
+    (showComparison2 && !!(comparison2Offset || comparison2Column));
+  // Match the Big Number family: the complete text stack is centered in the
+  // tile. The configured top values remain minimum padding, while the gap
+  // controls preserve the spacing inside the stack.
+  const contentHeight =
+    (hasTitle ? titleRowHeight + bigNumberTopValue : 0) +
+    bigNumberRowHeight +
+    (hasComparison ? comparisonTopValue + comparisonRowHeight : 0);
+  const contentTop = Math.max(titleTopValue, (height - contentHeight) / 2);
+  const effectiveTitleTop = hasTitle ? contentTop : titleTopValue;
+  const effectiveBigNumberTop = hasTitle
+    ? contentTop + titleRowHeight + bigNumberTopValue
+    : contentTop;
+  const effectiveComparisonTop =
+    effectiveBigNumberTop + bigNumberRowHeight + comparisonTopValue;
+
+  if (headerText) {
+    graphic.push({
+      type: 'text',
+      left: titleLeft,
+      top: effectiveTitleTop,
+      style: {
+        text: headerText,
+        fontSize: titleFontSizePx,
+        fill: toCssColor(titleColor, defaultTitleColor),
+      },
+    });
+  }
+
+  let bigNumberText = t('No data');
+  if (hasData && rawBigNumber !== null && rawBigNumber !== undefined) {
+    bigNumberText =
+      typeof rawBigNumber === 'number'
+        ? numberFormatter(rawBigNumber)
+        : String(rawBigNumber);

Review Comment:
   **Suggestion:** String-encoded big numbers bypass `numberFormatter`, so currency and number formats are ignored for valid string-valued query results.
   
   **Assessment:** 🟠 `Major` · 🔁 `Occurrence: Sometimes` · 🏷️ `Logic error`
   
   [![Use CodeAnt Skill](https://new-codeant-butcket.s3.us-west-1.amazonaws.com/badges/use-codeant-skill-flat-v2.svg)](https://docs.codeant.ai/cli/resolve-pr-comments-skill) [![Fix in Cursor](https://new-codeant-butcket.s3.us-west-1.amazonaws.com/badges/fix-in-cursor-flat.svg)](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=d526312b3968439c9da4affe4e666d4d&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [![Fix in VSCode Claude](https://new-codeant-butcket.s3.us-west-1.amazonaws.com/badges/fix-in-vscode-claude-flat.svg)](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=d526312b3968439c9da4affe4e666d4d&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
   <details>
   <summary><b>Prompt for AI Agent 🤖 </b></summary>
   
   ```mdx
   This is a comment left during a code review.
   
   **Path:** superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberYoyMom/transformProps.ts
   **Line:** 403:406
   **Comment:**
   	*Logic Error: String-encoded big numbers bypass `numberFormatter`, so currency and number formats are ignored for valid string-valued query results.
   
   Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
   Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
   ```
   </details>
   <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F44140&comment_hash=38eb9cb6d1ecf2a66680af1def5f8000732e3fc6ee213788e2401e1a0e7e7226&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F44140&comment_hash=38eb9cb6d1ecf2a66680af1def5f8000732e3fc6ee213788e2401e1a0e7e7226&reaction=dislike'>👎</a>



##########
superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberYoyMom/transformProps.ts:
##########
@@ -0,0 +1,612 @@
+/**
+ * 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 {
+  getMetricLabel,
+  getNumberFormatter,
+  getValueFormatter,
+  Metric,
+  NumberFormats,
+  QueryFormMetric,
+} from '@superset-ui/core';
+import type { EChartsCoreOption } from 'echarts/core';
+import type { GraphicComponentOption } from 'echarts/components';
+import {
+  DEFAULT_BIG_NUMBER_COLOR,
+  DEFAULT_BIG_NUMBER_FONT_SIZE,
+  DEFAULT_BIG_NUMBER_LEFT,
+  DEFAULT_COMPARISON1_LABEL,
+  DEFAULT_COMPARISON1_OFFSET,
+  DEFAULT_COMPARISON2_LABEL,
+  DEFAULT_COMPARISON_GAP,
+  DEFAULT_COMPARISON2_OFFSET,
+  DEFAULT_COMPARISON_FONT_SIZE,
+  DEFAULT_COMPARISON_NEGATIVE_COLOR,
+  DEFAULT_COMPARISON_POSITIVE_COLOR,
+  DEFAULT_COMPARISON_ZERO_COLOR,
+  DEFAULT_TITLE_COLOR,
+  DEFAULT_TITLE_FONT_SIZE,
+  DEFAULT_TITLE_LEFT,
+} from './constants';
+import {
+  BigNumberYoyMomChartProps,
+  BigNumberYoyMomProps,
+  RGBColor,
+} from './types';
+import {
+  alignToTimeGrain,
+  parseTimePointValue,
+  shiftPointToOffset,
+} from './timeRange';
+
+const toCssColor = (color?: RGBColor, fallback?: string): string =>
+  color ? `rgb(${color.r}, ${color.g}, ${color.b})` : (fallback ?? '');
+
+const toNumber = (value: unknown): number | null => {
+  if (value === null || value === undefined) return null;
+  if (typeof value === 'bigint') return Number(value);
+  const parsed = Number(value);
+  return Number.isNaN(parsed) ? null : parsed;
+};
+
+/**
+ * 'comparison1Left' -> 'comparison1_left'. Form data keys follow the
+ * snake_case control names Explore saves; callers sometimes pass camelCase.
+ */
+const camelToSnake = (key: string): string =>
+  key.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
+
+/**
+ * Normalize a position/gap control value. Any non-numeric input (blank
+ * string, null, NaN) is treated as 0, so clearing the control or typing
+ * invalid characters produces the same layout as an explicit 0.
+ */
+const normalizePosition = (value: unknown): number => {
+  const parsed = Number(value);
+  return Number.isNaN(parsed) ? 0 : parsed;
+};
+
+/**
+ * Read a comparison value column from a result row. Saved metrics are plain
+ * column names; adhoc metrics are named by their label, or by the SQL
+ * expression itself when no label is set. The backend may normalize
+ * whitespace in expression column names, so fall back to a
+ * whitespace-insensitive match before giving up.
+ */
+const readMetricValue = (
+  row: Record<string, unknown>,
+  column: QueryFormMetric,
+): number | string | null | undefined => {
+  const asScalar = (value: unknown): number | string | null | undefined =>
+    typeof value === 'number' || typeof value === 'string'
+      ? value
+      : value == null
+        ? null
+        : undefined;
+  if (typeof column === 'string') return asScalar(row[column]);
+  const sqlExpression =
+    column.expressionType === 'SQL' ? column.sqlExpression : undefined;
+  const candidates = [column.label, sqlExpression].filter(
+    (key): key is string => !!key,
+  );
+  for (const key of candidates) {
+    if (row[key] !== undefined) return asScalar(row[key]);
+  }
+  if (sqlExpression) {
+    const expression = sqlExpression.replace(/\s+/g, '');
+    const match = Object.keys(row).find(
+      key => key.replace(/\s+/g, '') === expression,
+    );
+    if (match) return asScalar(row[match]);
+  }
+  return undefined;
+};
+
+/**
+ * Percentage change between the current value and a comparison value.
+ * Mirrors the Big Number with Time Period Comparison behavior: no data on
+ * either side yields null (rendered as "—"), both zero yields 0%, and a zero
+ * denominator yields +/-100%.
+ */
+const computePercent = (
+  current: number | null,
+  previous: number | null,
+): number | null => {
+  if (current === null || previous === null) return null;
+  if (!current && !previous) return 0;
+  if (!previous) return current ? 1 : -1;
+  return (current - previous) / Math.abs(previous);
+};
+
+type ComparisonSlot = {
+  show: boolean;
+  label: string;
+  offset?: string;
+  column?: QueryFormMetric;
+  mode?: 'time_shift' | 'metric';
+  current: number | null;
+  comparisonValue: number | string | null | undefined;
+  formatter: (value: number) => string;
+  valueFormatter: (value: number) => string;
+};
+
+// A comparison slot reads its value either from a configured comparison
+// metric (dataset metric or custom SQL expression) or from the backend's
+// time-offset column `<metric label>__<offset>`. The mode mirrors buildQuery:
+// an unset mode falls back to the metric when a comparison column exists.
+const isMetricMode = (
+  mode: 'time_shift' | 'metric' | undefined,
+  column: QueryFormMetric | undefined,
+): boolean => mode === 'metric' || (!mode && !!column);
+
+export default function transformProps(
+  chartProps: BigNumberYoyMomChartProps,
+): BigNumberYoyMomProps {
+  const {
+    width,
+    height,
+    queriesData,
+    formData,
+    theme,
+    datasource: {
+      currencyFormats = {},
+      columnFormats = {},
+      currencyCodeColumn,
+    },
+  } = chartProps;
+  // Form data keys follow the snake_case control names that Explore saves.
+  // Tolerate camelCase too (older form data / direct callers) by falling
+  // back to the snake_case spelling when the camelCase key is absent.
+  const formDataRecord = formData as unknown as Record<string, unknown>;
+  const pick = <T>(key: string): T | undefined =>
+    (formDataRecord[key] ?? formDataRecord[camelToSnake(key)]) as T | undefined;
+  const metric = pick<string>('metric') ?? 'value';
+  const yAxisFormat = pick<string>('yAxisFormat');
+  const currencyFormat =
+    pick<Parameters<typeof getValueFormatter>[4]>('currencyFormat');
+  const headerText = pick<string>('headerText') ?? '';
+  const titleFontSize =
+    pick<number>('titleFontSize') ?? DEFAULT_TITLE_FONT_SIZE;
+  const titleColor = pick<RGBColor>('titleColor');
+  const titleLeft = pick<number>('titleLeft') ?? DEFAULT_TITLE_LEFT;
+  const titleTop = pick<number>('titleTop');
+  const bigNumberFontSize =
+    pick<number>('bigNumberFontSize') ?? DEFAULT_BIG_NUMBER_FONT_SIZE;
+  const bigNumberColor = pick<RGBColor>('bigNumberColor');
+  const bigNumberLeft =
+    pick<number>('bigNumberLeft') ?? DEFAULT_BIG_NUMBER_LEFT;
+  const bigNumberTop = pick<number>('bigNumberTop');
+  const showComparison1 = pick<boolean>('showComparison1') ?? true;
+  const comparison1Label =
+    pick<string>('comparison1Label') ?? t(DEFAULT_COMPARISON1_LABEL);
+  const comparison1Offset =
+    pick<string>('comparison1Offset') ?? DEFAULT_COMPARISON1_OFFSET;
+  const comparison1Column = pick<QueryFormMetric>('comparison1Column');
+  const comparison1PercentDifferenceFormat = pick<string>(
+    'comparison1PercentDifferenceFormat',
+  );
+  const legacyComparison1Left = pick<number>('comparison1Left');
+  const comparison1Mode = pick<'time_shift' | 'metric'>('comparison1Mode');
+  const showComparison2 = pick<boolean>('showComparison2') ?? true;
+  const comparison2Label =
+    pick<string>('comparison2Label') ?? t(DEFAULT_COMPARISON2_LABEL);
+  const comparison2Offset =
+    pick<string>('comparison2Offset') ?? DEFAULT_COMPARISON2_OFFSET;
+  const comparison2Column = pick<QueryFormMetric>('comparison2Column');
+  const comparison2PercentDifferenceFormat = pick<string>(
+    'comparison2PercentDifferenceFormat',
+  );
+  const legacyComparison2Left = pick<number>('comparison2Left');
+  const comparison2Mode = pick<'time_shift' | 'metric'>('comparison2Mode');
+  const comparisonGap = pick<number>('comparisonGap') ?? DEFAULT_COMPARISON_GAP;
+  const swapComparisonOrder = pick<boolean>('swapComparisonOrder') ?? false;
+  const comparisonFontSize =
+    pick<number>('comparisonFontSize') ?? DEFAULT_COMPARISON_FONT_SIZE;
+  const comparisonTop = pick<number>('comparisonTop');
+  const comparisonPositiveColor = pick<RGBColor>('comparisonPositiveColor');
+  const comparisonNegativeColor = pick<RGBColor>('comparisonNegativeColor');
+  const comparisonZeroColor = pick<RGBColor>('comparisonZeroColor');
+  const percentDifferenceFormat =
+    pick<string>('percentDifferenceFormat') ?? NumberFormats.PERCENT_2_POINT;
+  const timeGrainSqla = pick<string>('timeGrainSqla');
+
+  const defaultTitleColor =
+    theme?.colorTextSecondary ?? toCssColor(DEFAULT_TITLE_COLOR);
+  const defaultBigNumberColor =
+    theme?.colorText ?? toCssColor(DEFAULT_BIG_NUMBER_COLOR);
+  const defaultComparisonPositiveColor =
+    theme?.colorSuccess ?? toCssColor(DEFAULT_COMPARISON_POSITIVE_COLOR);
+  const defaultComparisonNegativeColor =
+    theme?.colorError ?? toCssColor(DEFAULT_COMPARISON_NEGATIVE_COLOR);
+  const defaultComparisonZeroColor =
+    theme?.colorTextTertiary ?? toCssColor(DEFAULT_COMPARISON_ZERO_COLOR);
+
+  const { data = [], detected_currency: detectedCurrency } =
+    queriesData[0] || {};
+  const hasData = data.length > 0;
+  const metricName = getMetricLabel(metric);
+
+  // Point-to-point comparison: buildQuery returns a second query with the
+  // metric grouped by the time column (newest first) when a time shift is
+  // configured without an enclosed time range ("No filter"). The comparison
+  // value for an offset is the series point that matches the newest point
+  // shifted by that offset; points that do not exist render as "—".
+  const seriesQuery = queriesData[1];
+  const seriesData = seriesQuery?.data ?? [];
+  const seriesTimeColumn = seriesQuery?.colnames?.[0];
+  const pointSeries = hasData && seriesData.length > 0 ? seriesData : null;
+  const seriesPoints = pointSeries
+    ? (pointSeries
+        .map(row => {
+          const time = seriesTimeColumn
+            ? parseTimePointValue(row[seriesTimeColumn])
+            : null;
+          return time ? { time, value: toNumber(row[metricName]) } : null;
+        })
+        .filter(
+          (point): point is { time: Date; value: number | null } =>
+            point !== null,
+        ) ?? [])
+    : [];
+  const latestPoint = seriesPoints.reduce(
+    (latest, point) =>
+      latest === null || point.time.getTime() > latest.time.getTime()
+        ? point
+        : latest,
+    null as { time: Date; value: number | null } | null,
+  );
+  const matchPointOffset = (offset: string | undefined): number | null => {
+    if (!offset || !latestPoint) return null;
+    const target = alignToTimeGrain(
+      shiftPointToOffset(latestPoint.time, offset),
+      timeGrainSqla,
+    );
+    const hit = seriesPoints.find(
+      point =>
+        alignToTimeGrain(point.time, timeGrainSqla).getTime() ===
+        target.getTime(),
+    );
+    return hit ? hit.value : null;
+  };
+
+  const metricEntry: Metric | undefined = chartProps.datasource?.metrics?.find(
+    metricItem => metricItem.metric_name === metric,
+  );
+
+  const numberFormatter = getValueFormatter(
+    metric,
+    currencyFormats,
+    columnFormats,
+    metricEntry?.d3format || yAxisFormat,
+    currencyFormat,
+    undefined,
+    data,
+    currencyCodeColumn,
+    detectedCurrency,
+  );
+  // MoM and YoY each carry their own percent difference format, falling back
+  // to the legacy global control for charts saved before the split.
+  // d3 percentage formats (e.g. ",.2%") scale the value by 100 and append the
+  // "%" sign themselves; plain number formats (",.2f", Smart Number) expect
+  // the already-scaled percentage value, so multiply by 100 before formatting.
+  const formatPercentValue = (
+    format: string | undefined,
+    percent: number,
+  ): string => {
+    const d3Format = format || percentDifferenceFormat;
+    const formatter = getNumberFormatter(d3Format);
+    return formatter(d3Format.includes('%') ? percent : percent * 100);
+  };
+  const percentFormatter1 = (value: number) =>
+    formatPercentValue(comparison1PercentDifferenceFormat, value);
+  const percentFormatter2 = (value: number) =>
+    formatPercentValue(comparison2PercentDifferenceFormat, value);
+  // Comparison value metrics render with the slot's configured number format
+  // (the same control used for percent differences), falling back to the
+  // default Smart Number format when unset.
+  const valueFormatter1 = (value: number) =>
+    getNumberFormatter(comparison1PercentDifferenceFormat)(value);
+  const valueFormatter2 = (value: number) =>
+    getNumberFormatter(comparison2PercentDifferenceFormat)(value);
+
+  const row = data[0] || {};
+  const rawBigNumber = hasData ? row[metricName] : null;
+  const current = toNumber(rawBigNumber);
+
+  const graphic: GraphicComponentOption[] = [];
+
+  // Auto-positioning keeps the text stack compact and centered, matching the
+  // flex-centered layout used by the other Big Number visualizations.
+  // The title font size follows the shared Subtitle Font Size control:
+  // ratios (<= 1, Tiny 0.125 .. Huge 0.4) are multiplied by the chart
+  // height; legacy numeric values > 1 are absolute pixels.
+  const hasTitle = !!headerText;
+  const titleFontSizeValue =
+    typeof titleFontSize === 'number'
+      ? titleFontSize
+      : Number(titleFontSize) || DEFAULT_TITLE_FONT_SIZE;
+  const titleFontSizePx =
+    titleFontSizeValue <= 1
+      ? Math.ceil(titleFontSizeValue * height)
+      : Math.ceil(titleFontSizeValue);
+  const bigNumberFontSizeValue =
+    typeof bigNumberFontSize === 'number'
+      ? bigNumberFontSize
+      : Number(bigNumberFontSize) || DEFAULT_BIG_NUMBER_FONT_SIZE;
+  const bigNumberFontSizePx =
+    bigNumberFontSizeValue <= 1
+      ? Math.ceil(bigNumberFontSizeValue * height)
+      : Math.ceil(bigNumberFontSizeValue);
+  const comparisonFontSizeValue =
+    typeof comparisonFontSize === 'number'
+      ? comparisonFontSize
+      : Number(comparisonFontSize) || DEFAULT_COMPARISON_FONT_SIZE;
+  const comparisonFontSizePx =
+    comparisonFontSizeValue <= 1
+      ? Math.ceil(comparisonFontSizeValue * height)
+      : Math.ceil(comparisonFontSizeValue);
+  const titleRowHeight = titleFontSizePx * 1.2;
+  const bigNumberRowHeight = bigNumberFontSizePx * 1.2;
+  const comparisonRowHeight = comparisonFontSizePx * 1.2;
+  const titleTopValue = normalizePosition(titleTop);
+  const bigNumberTopValue = normalizePosition(bigNumberTop);
+  const comparisonTopValue = normalizePosition(comparisonTop);
+  const hasComparison =
+    (showComparison1 && !!(comparison1Offset || comparison1Column)) ||
+    (showComparison2 && !!(comparison2Offset || comparison2Column));
+  // Match the Big Number family: the complete text stack is centered in the
+  // tile. The configured top values remain minimum padding, while the gap
+  // controls preserve the spacing inside the stack.
+  const contentHeight =
+    (hasTitle ? titleRowHeight + bigNumberTopValue : 0) +
+    bigNumberRowHeight +
+    (hasComparison ? comparisonTopValue + comparisonRowHeight : 0);
+  const contentTop = Math.max(titleTopValue, (height - contentHeight) / 2);
+  const effectiveTitleTop = hasTitle ? contentTop : titleTopValue;
+  const effectiveBigNumberTop = hasTitle
+    ? contentTop + titleRowHeight + bigNumberTopValue
+    : contentTop;
+  const effectiveComparisonTop =
+    effectiveBigNumberTop + bigNumberRowHeight + comparisonTopValue;
+
+  if (headerText) {
+    graphic.push({
+      type: 'text',
+      left: titleLeft,
+      top: effectiveTitleTop,
+      style: {
+        text: headerText,
+        fontSize: titleFontSizePx,
+        fill: toCssColor(titleColor, defaultTitleColor),
+      },
+    });
+  }
+
+  let bigNumberText = t('No data');
+  if (hasData && rawBigNumber !== null && rawBigNumber !== undefined) {
+    bigNumberText =
+      typeof rawBigNumber === 'number'
+        ? numberFormatter(rawBigNumber)
+        : String(rawBigNumber);
+  }
+
+  graphic.push({
+    type: 'text',
+    left: bigNumberLeft,
+    top: effectiveBigNumberTop,
+    style: {
+      text: bigNumberText,
+      fontSize: bigNumberFontSizePx,
+      fontWeight: 'bold',
+      fill: toCssColor(bigNumberColor, defaultBigNumberColor),
+    },
+  });
+
+  const positiveColor = toCssColor(
+    comparisonPositiveColor,
+    defaultComparisonPositiveColor,
+  );
+  const negativeColor = toCssColor(
+    comparisonNegativeColor,
+    defaultComparisonNegativeColor,
+  );
+  const zeroColor = toCssColor(comparisonZeroColor, defaultComparisonZeroColor);
+  const comparisonLeft = 20;
+  const comparisonGapPx = Math.max(0, Number(comparisonGap) || 0);
+
+  const slotComparisonValue = (
+    mode: 'time_shift' | 'metric' | undefined,
+    column: QueryFormMetric | undefined,
+    offset: string | undefined,
+  ): number | string | null | undefined => {
+    if (!hasData) return null;
+    if (isMetricMode(mode, column)) {
+      return column ? readMetricValue(row, column) : null;
+    }
+    if (pointSeries && offset) return matchPointOffset(offset);
+    return offset ? row[`${metricName}__${offset}`] : null;
+  };
+
+  // For point-to-point time shifts the percentage compares the newest series
+  // point against the shifted point (e.g. latest month vs previous month),
+  // not the overall main value. Other slots compare against the main value.
+  const slotCurrent = (
+    mode: 'time_shift' | 'metric' | undefined,
+    column: QueryFormMetric | undefined,
+  ): number | null => {
+    if (pointSeries && !isMetricMode(mode, column)) {
+      return latestPoint ? latestPoint.value : null;
+    }
+    return current;
+  };
+
+  const buildComparisonContent = ({
+    show,
+    label,
+    offset,
+    column,
+    mode,
+    current: slotCurrent,
+    comparisonValue,
+    formatter,
+    valueFormatter,
+  }: ComparisonSlot): { text: string; fill: string } | null => {
+    if (!show) return null;
+    if (!offset && !column) return null;
+    const comparison = toNumber(comparisonValue);
+    const missing = comparison === null;
+
+    // A comparison value metric (dataset column or custom SQL) supplies the
+    // value itself, so render it like the big number instead of computing a
+    // percentage change. Time shifts keep the percentage-difference display.
+    if (isMetricMode(mode, column)) {
+      if (missing) {
+        return {
+          text: `${label ? `${label} ` : ''}—`,
+          fill: zeroColor,
+        };
+      }
+      const arrow = comparison > 0 ? '↑' : comparison < 0 ? '↓' : '';
+      return {
+        text: `${label ? `${label} ` : ''}${arrow}${valueFormatter(
+          comparison,
+        )}`,
+        fill:
+          comparison > 0
+            ? positiveColor
+            : comparison < 0
+              ? negativeColor
+              : zeroColor,
+      };
+    }
+
+    const percent = computePercent(slotCurrent, comparison);
+    let text: string;
+    if (missing || percent === null) {
+      text = `${label ? `${label} ` : ''}—`;
+    } else {
+      const arrow = percent > 0 ? '↑' : percent < 0 ? '↓' : '';
+      text = `${label ? `${label} ` : ''}${arrow}${formatter(
+        Math.abs(percent),
+      )}`;
+    }
+    const fill =
+      missing || percent === null || percent === 0
+        ? zeroColor
+        : percent > 0
+          ? positiveColor
+          : negativeColor;
+    return { text, fill };
+  };
+
+  // Estimate the rendered width of a comparison line so the second slot is
+  // pushed past the first one even when no extra gap is configured. Full-width
+  // characters (labels, arrows) are wider than ASCII digits and separators.
+  const estimateTextWidth = (text: string, fontSizePx: number): number =>
+    Math.ceil(
+      Array.from(text).reduce(
+        (width, char) => width + (char.charCodeAt(0) > 255 ? 1.1 : 0.62),
+        0,
+      ) * fontSizePx,
+    );
+
+  const comparison1Content = buildComparisonContent({
+    show: showComparison1,
+    label: comparison1Label,
+    offset: comparison1Offset,
+    column: comparison1Column,
+    mode: comparison1Mode,
+    current: slotCurrent(comparison1Mode, comparison1Column),
+    comparisonValue: slotComparisonValue(
+      comparison1Mode,
+      comparison1Column,
+      comparison1Offset,
+    ),
+    formatter: percentFormatter1,
+    valueFormatter: valueFormatter1,
+  });
+  const comparison2Content = buildComparisonContent({
+    show: showComparison2,
+    label: comparison2Label,
+    offset: comparison2Offset,
+    column: comparison2Column,
+    mode: comparison2Mode,
+    current: slotCurrent(comparison2Mode, comparison2Column),
+    comparisonValue: slotComparisonValue(
+      comparison2Mode,
+      comparison2Column,
+      comparison2Offset,
+    ),
+    formatter: percentFormatter2,
+    valueFormatter: valueFormatter2,
+  });
+  const comparisonWidth = Math.max(
+    comparison1Content
+      ? estimateTextWidth(comparison1Content.text, comparisonFontSizePx)
+      : 0,
+    comparison2Content
+      ? estimateTextWidth(comparison2Content.text, comparisonFontSizePx)
+      : 0,
+  );
+  const comparisonLefts =
+    legacyComparison1Left !== undefined || legacyComparison2Left !== undefined
+      ? [
+          legacyComparison1Left ?? comparisonLeft,
+          legacyComparison2Left ??
+            comparisonLeft + comparisonWidth + comparisonGapPx,
+        ]
+      : swapComparisonOrder
+        ? [comparisonLeft + comparisonWidth + comparisonGapPx, comparisonLeft]
+        : [comparisonLeft, comparisonLeft + comparisonWidth + comparisonGapPx];

Review Comment:
   **Suggestion:** When only comparison 2 is visible, it is positioned after the estimated width of itself, leaving an unnecessary gap and potentially pushing it outside the tile.
   
   **Assessment:** 🟠 `Major` · 🔁 `Occurrence: Sometimes` · 🏷️ `Incorrect condition logic`
   
   [![Use CodeAnt Skill](https://new-codeant-butcket.s3.us-west-1.amazonaws.com/badges/use-codeant-skill-flat-v2.svg)](https://docs.codeant.ai/cli/resolve-pr-comments-skill) [![Fix in Cursor](https://new-codeant-butcket.s3.us-west-1.amazonaws.com/badges/fix-in-cursor-flat.svg)](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=90a2b742f353460db20e11905a58d5ec&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [![Fix in VSCode Claude](https://new-codeant-butcket.s3.us-west-1.amazonaws.com/badges/fix-in-vscode-claude-flat.svg)](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=90a2b742f353460db20e11905a58d5ec&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
   <details>
   <summary><b>Prompt for AI Agent 🤖 </b></summary>
   
   ```mdx
   This is a comment left during a code review.
   
   **Path:** superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberYoyMom/transformProps.ts
   **Line:** 572:576
   **Comment:**
   	*Incorrect Condition Logic: When only comparison 2 is visible, it is positioned after the estimated width of itself, leaving an unnecessary gap and potentially pushing it outside the tile.
   
   Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
   Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
   ```
   </details>
   <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F44140&comment_hash=2804a88f71162945158f4fcf51d0265e464a2b437ddf33381d821bae640d04d3&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F44140&comment_hash=2804a88f71162945158f4fcf51d0265e464a2b437ddf33381d821bae640d04d3&reaction=dislike'>👎</a>



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat(plugin-chart-echarts): add Big Number with YoY & MoM chart plugin [superset]

Posted by "rusackas (via GitHub)" <gi...@apache.org>.
rusackas commented on PR #44140:
URL: https://github.com/apache/superset/pull/44140#issuecomment-5769592973

   Thanks for the quick turnaround @liang-wenjie! The addMonths fix and the pre-commit/test cleanup look good, CI's green now. Codeant found a handful of new issues on the same two files after that push though, month-end/timezone edge cases in timeRange.ts and a few real-looking bugs in transformProps.ts (label collisions, the zero-comparison sign flip, the numberFormatter bypass). Worth a look before this merges.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org