Skip to content

SmartDateLabelProvider.formatSmartLabel bypasses overridden formatDateWide/formatDatePrecise at month boundaries #299

Description

@teo-basili

Problem

SmartDateLabelProvider (used by DateTimeNumericAxis) documents formatDateWide and
formatDatePrecise as the extension points for customizing axis label text — both carry JSDoc
saying "This method can be overridden to customize ... label formatting". That's true for every
label range except one specific case: when the label range is ETradeChartLabelFormat.Months
and a tick crosses a month boundary without also crossing a year boundary. In that case,
formatSmartLabel builds the label itself by calling the internal formatUnixDateToHumanStringMMM
util directly — a hardcoded, always-English, 3-letter month abbreviation — instead of calling
this.formatDateWide, which it calls for every other branch (first label, year change, every other
label range).

Impact: any subclass overriding formatDateWide/formatDatePrecise to localize or customize
month names works everywhere except this one branch, where it silently reverts to hardcoded
English — even though the rest of the axis's labels correctly reflect the override.

Root cause

SmartDateLabelProvider.prototype.formatSmartLabel = function (format, valueInSeconds, prevValueInSeconds, prevPrevValueInSeconds, originalRawValue) {
    var showWider = !this.firstLabel || this.showWiderDateOnFirstLabel;
    this.firstLabel = false;
    var wideDate = this.formatDateWide(format, valueInSeconds); // overridable, called here
    var prevWideDate = prevValueInSeconds !== undefined
        ? this.formatDateWide(format, prevValueInSeconds)
        : undefined;
    var isNewDate = prevWideDate === undefined || wideDate !== prevWideDate;
    if (format !== ETradeChartLabelFormat.Months) {
        if (isNewDate && showWider) { return wideDate; }
        return this.formatDatePrecise(format, valueInSeconds, originalRawValue); // overridable
    }
    else {
        if (isNewDate && showWider) { return wideDate; } // year boundary: goes through the override
        var prevPrevWideDate = /* ... */;
        var prevPrevNewYear = prevPrevWideDate === undefined || prevWideDate !== prevPrevWideDate;
        var newMonth = prevPrevNewYear ||
            (0, date_1.formatUnixDateToHumanStringMMM)(valueInSeconds) !==
                (0, date_1.formatUnixDateToHumanStringMMM)(prevValueInSeconds);
        if (newMonth) {
            // BUG: returns the raw util's output directly, never touching this.formatDateWide
            // or this.formatDatePrecise — the override has no effect here.
            return (0, date_1.formatUnixDateToHumanStringMMM)(valueInSeconds);
        }
        return this.formatDatePrecise(format, valueInSeconds, originalRawValue);
    }
};

formatDateWide's own default branch (used for Months) returns just the year, so once the
wide-format string hasn't changed — i.e. we're still in the same year as the previous tick — the
code falls into the newMonth branch as soon as the month itself changes (which is every tick, on
any reasonably-spaced axis). That branch calls formatUnixDateToHumanStringMMM directly,
instead of routing through any overridable method, so a subclass override has no effect there.

Fix

formatDateWide's Months case already means "the year" (per its own JSDoc example, "For years:
2020"
), so simply routing the newMonth branch through this.formatDateWide would collide with
that existing meaning. This adds a new, dedicated overridable method — defaulting to exactly
today's hardcoded behavior, so the change is fully backward compatible — and routes the newMonth
branch through it instead of calling the internal util directly:

--- a/Charting/Visuals/Axis/LabelProvider/SmartDateLabelProvider.js
+++ b/Charting/Visuals/Axis/LabelProvider/SmartDateLabelProvider.js
@@ formatDatePrecise (end of method) @@
             return (0, number_1.formatNumber)(valueInSeconds, NumericFormat_1.ENumericFormat.Date_DDMMYY, 0);
         }
     };
+    /**
+     * Formats the month-only label shown when a tick crosses a month boundary but not a year
+     * boundary, at the `Months` label range (e.g. "Jan", "Feb"). This method can be overridden to
+     * customize or localize this specific label, independently of {@link formatDateWide} (whose
+     * own `Months` case returns the year, not the month) and {@link formatDatePrecise}.
+     *
+     * @param valueInSeconds - The data value converted to Unix seconds with dateOffset applied
+     * @returns The formatted month-only label string
+     *
+     * @example
+     * // Default: "Jan", "Feb", ...
+     */
+    SmartDateLabelProvider.prototype.formatMonthLabel = function (valueInSeconds) {
+        return (0, date_1.formatUnixDateToHumanStringMMM)(valueInSeconds);
+    };
     SmartDateLabelProvider.prototype.toJSON = function () {
@@ formatSmartLabel, newMonth branch @@
             var newMonth = prevPrevNewYear ||
                 (0, date_1.formatUnixDateToHumanStringMMM)(valueInSeconds) !==
                     (0, date_1.formatUnixDateToHumanStringMMM)(prevValueInSeconds);
             if (newMonth) {
-                return (0, date_1.formatUnixDateToHumanStringMMM)(valueInSeconds);
+                return this.formatMonthLabel(valueInSeconds);
             }
             return this.formatDatePrecise(format, valueInSeconds, originalRawValue);
         }

--- a/Charting/Visuals/Axis/LabelProvider/SmartDateLabelProvider.d.ts
+++ b/Charting/Visuals/Axis/LabelProvider/SmartDateLabelProvider.d.ts
@@ class SmartDateLabelProvider @@
     formatDatePrecise(labelRange: ETradeChartLabelFormat | string, valueInSeconds: number, rawValue?: number): string;
+    /**
+     * Formats the month-only label shown when a tick crosses a month boundary but not a year
+     * boundary, at the `Months` label range (e.g. "Jan", "Feb"). This method can be overridden to
+     * customize or localize this specific label, independently of {@link formatDateWide} (whose
+     * own `Months` case returns the year, not the month) and {@link formatDatePrecise}.
+     *
+     * @param valueInSeconds - The data value converted to Unix seconds with dateOffset applied
+     * @returns The formatted month-only label string
+     *
+     * @example
+     * // Default: "Jan", "Feb", ...
+     */
+    formatMonthLabel(valueInSeconds: number): string;
     toJSON(): {
         type: string;
         options: Required<Omit<import("./LabelProvider").ILabelOptions, never>>;

This gives subclasses the same kind of override hook formatDateWide/formatDatePrecise already
provide, without changing any existing default output.

Testing

Verified by monkey-patching a 5.2.62 build with the exact change above and rerunning a
SmartDateLabelProvider subclass that overrides formatDateWide/formatDatePrecise to reverse
their output, on a DateTimeNumericAxis spanning several years of monthly ticks:

  • Before the fix: year-boundary labels render reversed (e.g. "5202"), confirming the override
    is invoked there, while month-boundary labels render as plain, unreversed English ("Jan",
    "Feb", ...), confirming formatDateWide is bypassed.
  • After the fix: with the new formatMonthLabel override added to the same subclass, month
    labels are reversed too ("naJ", "beF", ...) — the override is now honored everywhere.

No race, no timing dependency, no change to default (non-overridden) output — deterministic every
run.

index.html

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions