PageSourceSearch

https://www.hotelmanagement-network.com/wp-content/themes/goodlife…/assets/js/modules/GDMDateLineChart.js

js hotelmanagement-network.com collected 2026-10-02 04:29:28 UTC 22,162 bytes, 732 lines download raw bytes

1const DAY_IN_MS = 24 * 60 * 60 * 1000;
2const DEFAULT_FAKE_DAYS = 92;
3const HIGHCHARTS_SCRIPT = "https://code.highcharts.com/highcharts.js";
4const LINE_COLOR = "#427dd6";
5const GRID_COLOR = "#d3d5db";
6const AXIS_COLOR = "#7e8187";
7const LABEL_COLOR = "#151529";
8
9let highchartsLoader;
10
11const loadScriptOnce = (url) =>
12  new Promise((resolve, reject) => {
13    const existing = Array.from(document.getElementsByTagName("script")).find(
14      (script) => script.src === url,
15    );
16
17    if (existing) {
18      if (existing.getAttribute("data-loaded") === "true") {
19        resolve();
20        return;
21      }
22
23      existing.addEventListener("load", () => resolve(), { once: true });
24      existing.addEventListener(
25        "error",
26        () => reject(new Error(`Could not load script: ${url}`)),
27        { once: true },
28      );
29      return;
30    }
31
32    const script = document.createElement("script");
33    script.src = url;
34    script.async = true;
35    script.addEventListener(
36      "load",
37      () => {
38        script.setAttribute("data-loaded", "true");
39        resolve();
40      },
41      { once: true },
42    );
43    script.addEventListener(
44      "error",
45      () => reject(new Error(`Could not load script: ${url}`)),
46      { once: true },
47    );
48    document.head.appendChild(script);
49  });
50
51const ensureHighcharts = async () => {
52  if (window.Highcharts) {
53    return window.Highcharts;
54  }
55
56  // If highmaps.js is already on the page (loaded by GDMRegionChart), wait for it
57  // instead of loading core highcharts.js which would cause error #16
58  const existingHighmaps = Array.from(document.getElementsByTagName("script")).find(
59    (s) => s.src && s.src.includes("highmaps")
60  );
61
62  if (existingHighmaps) {
63    await new Promise((resolve) => {
64      if (existingHighmaps.getAttribute("data-loaded") === "true") { resolve(); return; }
65      existingHighmaps.addEventListener("load", resolve, { once: true });
66      existingHighmaps.addEventListener("error", resolve, { once: true });
67    });
68    return window.Highcharts;
69  }
70
71  if (!highchartsLoader) {
72    highchartsLoader = loadScriptOnce(HIGHCHARTS_SCRIPT);
73  }
74
75  await highchartsLoader;
76  return window.Highcharts;
77};
78
79const atNoon = (date) => {
80  const value = new Date(date);
81  value.setHours(12, 0, 0, 0);
82  return value;
83};
84
85const formatInputValue = (date) => {
86  const year = date.getFullYear();
87  const month = String(date.getMonth() + 1).padStart(2, "0");
88  const day = String(date.getDate()).padStart(2, "0");
89  return `${year}-${month}-${day}`;
90};
91
92const parseInputValue = (value) => {
93  const [year, month, day] = value.split("-").map(Number);
94  if (!year || !month || !day) return null;
95
96  return atNoon(new Date(year, month - 1, day));
97};
98
99const formatRangeLabel = (startDate, endDate) => {
100  const options = { month: "short", day: "numeric" };
101  return `${startDate.toLocaleDateString("en-US", options)} - ${endDate.toLocaleDateString("en-US", options)}`;
102};
103
104const getOrdinalSuffix = (day) => {
105  const teens = day % 100;
106  if (teens >= 11 && teens <= 13) return "th";
107
108  const lastDigit = day % 10;
109  if (lastDigit === 1) return "st";
110  if (lastDigit === 2) return "nd";
111  if (lastDigit === 3) return "rd";
112  return "th";
113};
114
115const formatTooltipDate = (timestamp) => {
116  const date = new Date(timestamp);
117  const day = date.getDate();
118  const month = date.toLocaleDateString("en-US", { month: "short" });
119  const year = String(date.getFullYear()).slice(-2);
120  return `${day}${getOrdinalSuffix(day)} ${month} '${year}`;
121};
122
123const getDefaultRange = (defaultDays) => {
124  const endDate = atNoon(new Date());
125  const startDate = atNoon(endDate);
126  startDate.setDate(startDate.getDate() - defaultDays);
127
128  return { startDate, endDate };
129};
130
131const clampRange = (range, minDate, maxDate) => {
132  const startDate = atNoon(range.startDate < minDate ? minDate : range.startDate);
133  const endDate = atNoon(range.endDate > maxDate ? maxDate : range.endDate);
134
135  if (startDate.getTime() > endDate.getTime()) {
136    return { startDate: atNoon(minDate), endDate: atNoon(maxDate) };
137  }
138
139  return { startDate, endDate };
140};
141
142const enumerateDates = (startDate, endDate) => {
143  const dates = [];
144  const cursor = atNoon(startDate);
145  const lastDate = atNoon(endDate).getTime();
146
147  while (cursor.getTime() <= lastDate) {
148    dates.push(new Date(cursor));
149    cursor.setDate(cursor.getDate() + 1);
150  }
151
152  return dates;
153};
154
155const getValueForDate = (date) => {
156  const seed = Math.round(date.getTime() / DAY_IN_MS);
157  const value =
158    220 +
159    Math.round(
160      ((Math.sin(seed * 0.91) + 1) * 220) +
161        ((Math.cos(seed * 1.73 + 0.8) + 1) * 180) +
162        ((Math.sin(seed * 2.61 + 1.4) + 1) * 140),
163    );
164
165  return Math.max(220, Math.min(1140, value));
166};
167
168const buildDataset = (endDate, totalDays) => {
169  const startDate = atNoon(endDate);
170  startDate.setDate(startDate.getDate() - (totalDays - 1));
171
172  return enumerateDates(startDate, endDate).map((date) => [date.getTime(), getValueForDate(date)]);
173};
174
175const filterDataset = (dataset, startDate, endDate) => {
176  const startTime = atNoon(startDate).getTime();
177  const endTime = atNoon(endDate).getTime();
178
179  return dataset.filter(([timestamp]) => timestamp >= startTime && timestamp <= endTime);
180};
181
182const getYAxisMax = (points) => {
183  const peak = Math.max(...points.map(([, value]) => value), 0);
184  if (peak === 0) return 1200;
185  const step = peak < 500 ? 50 : peak < 2000 ? 200 : peak < 10000 ? 1000 : 5000;
186  return Math.ceil(peak / step) * step + step;
187};
188
189const getTickStep = (yAxisMax) => {
190  if (yAxisMax <= 500) return 50;
191  if (yAxisMax <= 2400) return 200;
192  if (yAxisMax <= 12000) return 1000;
193  return 5000;
194};
195
196const getTickInterval = (pointsLength) => {
197  if (pointsLength <= 16) return DAY_IN_MS * 2;
198  if (pointsLength <= 45) return DAY_IN_MS * 5;
199  return DAY_IN_MS * 7;
200};
201
202const isRootActive = (root) => {
203  const panel = root.closest("[data-toggle-content-key]");
204  if (!panel) return true;
205
206  return panel.classList.contains("is-active") && panel.getAttribute("aria-hidden") !== "true";
207};
208
209const scheduleWhenIdle = (callback) => {
210  if (typeof window.requestIdleCallback === "function") {
211    window.requestIdleCallback(callback, { timeout: 1500 });
212    return;
213  }
214
215  window.setTimeout(callback, 0);
216};
217
218const getChartMetric = (root, propertyName, fallbackValue) => {
219  const value = Number.parseFloat(getComputedStyle(root).getPropertyValue(propertyName));
220  return Number.isFinite(value) ? value : fallbackValue;
221};
222
223const buildMarkup = (title, chartId, pickerId, rangeLabel, showRangeTrigger) => {
224  return `
225    <header class="header">
226      <h3 class="title">${title}</h3>
227      ${showRangeTrigger ? `<div class="range-wrap">
228        <button type="button" class="range-trigger" data-gdm-date-trigger aria-haspopup="dialog" aria-controls="${pickerId}" aria-expanded="false" style="pointer-events:none;cursor:default;">
229          <span class="range-value" data-gdm-date-label>${rangeLabel}</span>
230        </button>
231        <div id="${pickerId}" class="picker" data-gdm-date-picker hidden></div>
232      </div>` : ""}
233    </header>
234    <div id="${chartId}" class="plot" aria-label="${title}"></div>
235  `;
236};
237
238export default function GDMDateLineChart({ rootSelector = "[data-gdm-date-line-chart]", showRangeTrigger = false } = {}) {
239  const roots = document.querySelectorAll(rootSelector);
240  if (!roots.length) return;
241
242  const instances = [];
243
244  roots.forEach((root, index) => {
245    const availableDays = Math.max(
246      30,
247      Number.parseInt(root.getAttribute("data-fake-days") || `${DEFAULT_FAKE_DAYS}`, 10) || DEFAULT_FAKE_DAYS,
248    );
249    const defaultDays = Math.max(
250      1,
251      Number.parseInt(root.getAttribute("data-default-days") || "14", 10) || 14,
252    );
253    const title = root.getAttribute("data-chart-title") || "Jobs listed by date published";
254    const chartId = `gdm-date-line-chart-${index}`;
255    const pickerId = `gdm-date-line-picker-${index}`;
256    const datasetEndDate = atNoon(new Date());
257    const dataset = buildDataset(datasetEndDate, availableDays);
258    const datasetStartDate = new Date(dataset[0][0]);
259    const datasetMaxDate = new Date(dataset[dataset.length - 1][0]);
260
261    let range = clampRange(getDefaultRange(defaultDays), datasetStartDate, datasetMaxDate);
262    let chart;
263    let hasRendered = false;
264    let isRendering = false;
265    let pendingRender = false;
266    let panelObserver;
267    let pickerReady = false;
268    let resizeTimer;
269
270    let liveDataset = null;
271    let liveMinDate = datasetStartDate;
272    let liveMaxDate = datasetMaxDate;
273
274    root.innerHTML = buildMarkup(title, chartId, pickerId, formatRangeLabel(range.startDate, range.endDate), showRangeTrigger);
275
276    const trigger = root.querySelector("[data-gdm-date-trigger]");
277    const picker = root.querySelector("[data-gdm-date-picker]");
278    const label = root.querySelector("[data-gdm-date-label]");
279
280    const closePicker = () => {
281      picker.hidden = true;
282      trigger?.setAttribute("aria-expanded", "false");
283    };
284
285    const updateLabel = () => {
286      label.textContent = formatRangeLabel(range.startDate, range.endDate);
287    };
288
289    const renderChart = async () => {
290      if (isRendering) {
291        pendingRender = true;
292        return;
293      }
294      isRendering = true;
295      pendingRender = false;
296
297      // Destroy before the async gap so no second call can race into Highcharts.chart()
298      if (chart) {
299        chart.destroy();
300        chart = null;
301      }
302
303      let Highcharts;
304      try {
305        Highcharts = await ensureHighcharts();
306      } catch (err) {
307        isRendering = false;
308        return;
309      }
310
311      const points = liveDataset
312        ? filterDataset(liveDataset, range.startDate, range.endDate)
313        : filterDataset(dataset, range.startDate, range.endDate);
314      const yAxisMax = getYAxisMax(points);
315      const step = getTickStep(yAxisMax);
316      const tickPositions = Array.from(
317        { length: Math.floor(yAxisMax / step) + 1 },
318        (_, tickIndex) => tickIndex * step,
319      );
320      const tickInterval = getTickInterval(points.length);
321      const useMonthLabels = true;
322      const lineWidth = getChartMetric(root, "--gdm-date-line-width", 2);
323      const axisLabelSize = getChartMetric(root, "--gdm-date-axis-label-size", 12);
324      const pointDiameter = getChartMetric(root, "--gdm-date-point-diameter", 9);
325      const pointRadius = pointDiameter / 2;
326      const pointStrokeWidth = getChartMetric(root, "--gdm-date-point-stroke-width", 2);
327
328      try {
329      chart = Highcharts.chart(chartId, {
330        chart: {
331          backgroundColor: "transparent",
332          spacing: [24, 8, 0, 0],
333          style: {
334            fontFamily: "inherit",
335          },
336        },
337        title: {
338          text: null,
339        },
340        credits: {
341          enabled: false,
342        },
343        legend: {
344          enabled: false,
345        },
346        tooltip: {
347          enabled: true,
348          useHTML: true,
349          backgroundColor: "transparent",
350          borderWidth: 0,
351          borderRadius: 0,
352          padding: 0,
353          shadow: {
354            color: "rgba(0, 0, 0, 0.18)",
355            offsetX: 0,
356            offsetY: 6,
357            opacity: 0.2,
358            width: 12,
359          },
360          positioner(labelWidth, labelHeight, point) {
361            return {
362              x: this.chart.plotLeft + point.plotX + pointRadius,
363              y: this.chart.plotTop + point.plotY + pointRadius,
364            };
365          },
366          formatter() {
367            const value = Number.isFinite(this.y) ? Math.round(this.y) : 0;
368            return `
369              <div style="line-height:1.2; color:#2a2d59; min-width:10rem; max-width:30rem; width:fit-content; background:#ffffff; border:1px solid #c4c8d0; border-radius:0.6rem; padding:0.8rem 1rem;">
370                <div style="font-size:1.2rem; font-weight:700; letter-spacing:0.02em; margin-bottom:0.35rem;">${formatTooltipDate(this.x)}</div>
371                <div style="font-size:1.6rem; font-weight:500; letter-spacing:0.01em;">${value.toLocaleString("en-US")} Jobs</div>
372              </div>
373            `;
374          },
375        },
376        xAxis: {
377          type: "datetime",
378          tickInterval,
379          lineColor: AXIS_COLOR,
380          lineWidth: 1,
381          tickColor: AXIS_COLOR,
382          tickLength: 16,
383          minPadding: 0.04,
384          maxPadding: 0.04,
385          labels: {
386            y: 32,
387            style: {
388              color: LABEL_COLOR,
389              fontSize: `${axisLabelSize}px`,
390              fontWeight: "400",
391            },
392            formatter() {
393              return Highcharts.dateFormat(useMonthLabels ? "%e %b" : "%e", this.value);
394            },
395          },
396        },
397        yAxis: {
398          title: {
399            text: null,
400          },
401          min: 0,
402          max: yAxisMax,
403          tickPositions,
404          gridLineColor: GRID_COLOR,
405          gridLineDashStyle: "Dash",
406          gridLineWidth: 1,
407          lineColor: AXIS_COLOR,
408          lineWidth: 1,
409          tickLength: 0,
410          labels: {
411            x: -20,
412            style: {
413              color: LABEL_COLOR,
414              fontSize: `${axisLabelSize}px`,
415              fontWeight: "500",
416            },
417            formatter() {
418              return this.value.toLocaleString("en-US");
419            },
420          },
421        },
422        plotOptions: {
423          series: {
424            animation: false,
425            states: {
426              inactive: {
427                opacity: 1,
428              },
429            },
430          },
431          line: {
432            lineWidth,
433            color: LINE_COLOR,
434            marker: {
435              enabled: true,
436              radius: pointRadius,
437              fillColor: "#ffffff",
438              lineColor: LINE_COLOR,
439              lineWidth: pointStrokeWidth,
440              states: {
441                hover: {
442                  enabled: true,
443                  radius: pointRadius,
444                  fillColor: "#ffffff",
445                  lineColor: LINE_COLOR,
446                  lineWidth: pointStrokeWidth,
447                },
448              },
449            },
450            states: {
451              hover: {
452                lineWidthPlus: 0,
453              },
454            },
455          },
456        },
457        series: [
458          {
459            type: "line",
460            data: points,
461            enableMouseTracking: true,
462          },
463        ],
464      });
465
466      const series = chart.series?.[0];
467      if (!series) return;
468
469      const isInsidePlot = (normalized) => {
470        return (
471          normalized.chartX >= chart.plotLeft &&
472          normalized.chartX <= chart.plotLeft + chart.plotWidth &&
473          normalized.chartY >= chart.plotTop &&
474          normalized.chartY <= chart.plotTop + chart.plotHeight
475        );
476      };
477
478      const getNearestPoint = (normalized) => {
479        let nearestPoint = null;
480        let nearestDistance = Number.POSITIVE_INFINITY;
481
482        series.points.forEach((point) => {
483          if (!Number.isFinite(point.plotX)) return;
484          const pointChartX = chart.plotLeft + point.plotX;
485          const distance = Math.abs(pointChartX - normalized.chartX);
486
487          if (distance < nearestDistance) {
488            nearestDistance = distance;
489            nearestPoint = point;
490          }
491        });
492
493        return nearestPoint;
494      };
495
496      const handlePointerMove = (event) => {
497        const normalized = chart.pointer.normalize(event);
498        if (!isInsidePlot(normalized)) {
499          chart.pointer.reset();
500          return;
501        }
502
503        const point = getNearestPoint(normalized);
504        if (!point) return;
505
506        point.setState("hover");
507        chart.tooltip.refresh(point);
508      };
509
510      const handlePointerLeave = () => {
511        chart.pointer.reset();
512      };
513
514      chart.container.addEventListener("mousemove", handlePointerMove);
515      chart.container.addEventListener("touchmove", handlePointerMove, { passive: true });
516      chart.container.addEventListener("mouseleave", handlePointerLeave);
517      } catch (err) {
518        console.error("GDMDateLineChart render error", err);
519      } finally {
520        isRendering = false;
521        if (pendingRender) renderChart();
522      }
523    };
524
525    const ensurePicker = () => {
526      if (pickerReady) return;
527
528      picker.innerHTML = `
529        <div class="picker-grid">
530          <label>
531            <span>Start date</span>
532            <input type="date" data-gdm-date-start>
533          </label>
534          <label>
535            <span>End date</span>
536            <input type="date" data-gdm-date-end>
537          </label>
538        </div>
539        <div class="picker-actions">
540          <button type="button" class="picker-button" data-gdm-date-cancel>Cancel</button>
541          <button type="button" class="picker-button is-primary" data-gdm-date-apply>Apply</button>
542        </div>
543      `;
544
545      const startInput = picker.querySelector("[data-gdm-date-start]");
546      const endInput = picker.querySelector("[data-gdm-date-end]");
547      const cancelButton = picker.querySelector("[data-gdm-date-cancel]");
548      const applyButton = picker.querySelector("[data-gdm-date-apply]");
549
550      const syncInputs = () => {
551        startInput.value = formatInputValue(range.startDate);
552        endInput.value = formatInputValue(range.endDate);
553        startInput.min = formatInputValue(liveMinDate);
554        startInput.max = endInput.value;
555        endInput.max = formatInputValue(liveMaxDate);
556        endInput.min = startInput.value;
557      };
558
559      syncInputs();
560
561      startInput.addEventListener("change", () => {
562        endInput.min = startInput.value;
563      });
564
565      endInput.addEventListener("change", () => {
566        startInput.max = endInput.value;
567      });
568
569      cancelButton.addEventListener("click", () => {
570        syncInputs();
571        closePicker();
572      });
573
574      applyButton.addEventListener("click", async () => {
575        const startDate = parseInputValue(startInput.value);
576        const endDate = parseInputValue(endInput.value);
577        if (!startDate || !endDate || startDate.getTime() > endDate.getTime()) return;
578
579        range = clampRange({ startDate, endDate }, liveMinDate, liveMaxDate);
580        updateLabel();
581        closePicker();
582
583        root.dispatchEvent(new CustomEvent("gdm:date-apply", {
584          bubbles: true,
585          detail: {
586            startDate: formatInputValue(range.startDate),
587            endDate: formatInputValue(range.endDate),
588          },
589        }));
590
591        if (!hasRendered) {
592          await startRender();
593          return;
594        }
595
596        await renderChart();
597      });
598
599      picker.addEventListener("keydown", (event) => {
600        if (event.key === "Escape") {
601          closePicker();
602          trigger?.focus();
603        }
604
605        if (event.key === "Enter" && event.target.tagName === "INPUT") {
606          event.preventDefault();
607          applyButton.click();
608        }
609      });
610
611      pickerReady = true;
612      picker.syncInputs = syncInputs;
613    };
614
615    const openPicker = () => {
616      ensurePicker();
617      picker.syncInputs?.();
618      picker.hidden = false;
619      trigger?.setAttribute("aria-expanded", "true");
620
621      const startInput = picker.querySelector("[data-gdm-date-start]");
622      if (typeof startInput?.showPicker === "function") {
623        startInput.showPicker();
624      } else {
625        startInput?.focus();
626      }
627    };
628
629    const startRender = async () => {
630      if (hasRendered) return;
631      hasRendered = true;
632      panelObserver?.disconnect();
633      await renderChart();
634    };
635
636    if (trigger && showRangeTrigger) {
637      trigger.addEventListener("click", (event) => {
638        event.stopPropagation();
639
640        if (picker.hidden) {
641          openPicker();
642          return;
643        }
644
645        closePicker();
646      });
647    }
648
649    picker?.addEventListener("click", (event) => {
650      event.stopPropagation();
651    });
652
653    document.addEventListener("click", (event) => {
654      if (!showRangeTrigger) return;
655      if (!root.contains(event.target)) {
656        closePicker();
657      }
658    });
659
660    window.addEventListener("resize", () => {
661      if (!hasRendered || !isRootActive(root)) return;
662
663      window.clearTimeout(resizeTimer);
664      resizeTimer = window.setTimeout(() => {
665        renderChart();
666      }, 120);
667    });
668
669    const instance = {
670      root,
671      /**
672       * Re-render with live dates from the analytics AJAX response.
673       * @param {Array<{date: string, count: number}>} dates
674       */
675      setData(dates) {
676        if (!Array.isArray(dates) || !dates.length) return;
677
678        const parseLocalDate = (str) => {
679          const [y, m, d] = String(str).split("-").map(Number);
680          return new Date(y, m - 1, d);
681        };
682
683        const sorted = dates.slice().sort((a, b) => (a.date < b.date ? -1 : 1));
684        liveDataset = sorted.map(({ date, count }) => [
685          atNoon(parseLocalDate(date)).getTime(),
686          Math.max(0, Number(count) || 0),
687        ]);
688
689        liveMinDate = new Date(liveDataset[0][0]);
690        liveMaxDate = new Date(liveDataset[liveDataset.length - 1][0]);
691
692        // Default view: last 30 days of the live data
693        const last30Start = atNoon(new Date(liveMaxDate));
694        last30Start.setDate(last30Start.getDate() - 30);
695        range = clampRange({ startDate: last30Start, endDate: liveMaxDate }, liveMinDate, liveMaxDate);
696
697        if (label) updateLabel();
698        if (pickerReady) picker.syncInputs?.();
699        hasRendered = true;
700        panelObserver?.disconnect();
701        renderChart();
702      },
703    };
704
705    instances.push(instance);
706
707    if (isRootActive(root)) {
708      scheduleWhenIdle(() => {
709        startRender();
710      });
711    } else {
712      const panel = root.closest("[data-toggle-content-key]");
713      if (panel && typeof MutationObserver === "function") {
714        panelObserver = new MutationObserver(() => {
715          if (!isRootActive(root)) return;
716
717          scheduleWhenIdle(() => {
718            startRender();
719          });
720        });
721
722        panelObserver.observe(panel, {
723          attributes: true,
724          attributeFilter: ["class", "aria-hidden"],
725        });
726      }
727    }
728  });
729
730  window.GDMDateLineChartInstances = instances;
731  return instances;
732}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.