PageSourceSearch

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

js hotelmanagement-network.com collected 2026-09-24 18:37:03 UTC 18,883 bytes, 632 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?.chart) {
53    return window.Highcharts;
54  }
55
56  if (!highchartsLoader) {
57    highchartsLoader = loadScriptOnce(HIGHCHARTS_SCRIPT);
58  }
59
60  await highchartsLoader;
61  return window.Highcharts;
62};
63
64const atNoon = (date) => {
65  const value = new Date(date);
66  value.setHours(12, 0, 0, 0);
67  return value;
68};
69
70const formatInputValue = (date) => {
71  const year = date.getFullYear();
72  const month = String(date.getMonth() + 1).padStart(2, "0");
73  const day = String(date.getDate()).padStart(2, "0");
74  return `${year}-${month}-${day}`;
75};
76
77const parseInputValue = (value) => {
78  const [year, month, day] = value.split("-").map(Number);
79  if (!year || !month || !day) return null;
80
81  return atNoon(new Date(year, month - 1, day));
82};
83
84const formatRangeLabel = (startDate, endDate) => {
85  const options = { month: "short", day: "numeric" };
86  return `${startDate.toLocaleDateString("en-US", options)} - ${endDate.toLocaleDateString("en-US", options)}`;
87};
88
89const getOrdinalSuffix = (day) => {
90  const teens = day % 100;
91  if (teens >= 11 && teens <= 13) return "th";
92
93  const lastDigit = day % 10;
94  if (lastDigit === 1) return "st";
95  if (lastDigit === 2) return "nd";
96  if (lastDigit === 3) return "rd";
97  return "th";
98};
99
100const formatTooltipDate = (timestamp) => {
101  const date = new Date(timestamp);
102  const day = date.getDate();
103  const month = date.toLocaleDateString("en-US", { month: "short" });
104  const year = String(date.getFullYear()).slice(-2);
105  return `${day}${getOrdinalSuffix(day)} ${month} '${year}`;
106};
107
108const getDefaultRange = (defaultDays) => {
109  const endDate = atNoon(new Date());
110  const startDate = atNoon(endDate);
111  startDate.setDate(startDate.getDate() - defaultDays);
112
113  return { startDate, endDate };
114};
115
116const clampRange = (range, minDate, maxDate) => {
117  const startDate = atNoon(range.startDate < minDate ? minDate : range.startDate);
118  const endDate = atNoon(range.endDate > maxDate ? maxDate : range.endDate);
119
120  if (startDate.getTime() > endDate.getTime()) {
121    return { startDate: atNoon(minDate), endDate: atNoon(maxDate) };
122  }
123
124  return { startDate, endDate };
125};
126
127const enumerateDates = (startDate, endDate) => {
128  const dates = [];
129  const cursor = atNoon(startDate);
130  const lastDate = atNoon(endDate).getTime();
131
132  while (cursor.getTime() <= lastDate) {
133    dates.push(new Date(cursor));
134    cursor.setDate(cursor.getDate() + 1);
135  }
136
137  return dates;
138};
139
140const getValueForDate = (date) => {
141  const seed = Math.round(date.getTime() / DAY_IN_MS);
142  const value =
143    220 +
144    Math.round(
145      ((Math.sin(seed * 0.91) + 1) * 220) +
146        ((Math.cos(seed * 1.73 + 0.8) + 1) * 180) +
147        ((Math.sin(seed * 2.61 + 1.4) + 1) * 140),
148    );
149
150  return Math.max(220, Math.min(1140, value));
151};
152
153const buildDataset = (endDate, totalDays) => {
154  const startDate = atNoon(endDate);
155  startDate.setDate(startDate.getDate() - (totalDays - 1));
156
157  return enumerateDates(startDate, endDate).map((date) => [date.getTime(), getValueForDate(date)]);
158};
159
160const filterDataset = (dataset, startDate, endDate) => {
161  const startTime = atNoon(startDate).getTime();
162  const endTime = atNoon(endDate).getTime();
163
164  return dataset.filter(([timestamp]) => timestamp >= startTime && timestamp <= endTime);
165};
166
167const getYAxisMax = (points) => {
168  const peak = Math.max(...points.map(([, value]) => value), 1200);
169  return Math.ceil(peak / 200) * 200 + 200;
170};
171
172const getTickInterval = (pointsLength) => {
173  if (pointsLength <= 16) return DAY_IN_MS * 2;
174  if (pointsLength <= 45) return DAY_IN_MS * 5;
175  return DAY_IN_MS * 7;
176};
177
178const isRootActive = (root) => {
179  const panel = root.closest("[data-toggle-content-key]");
180  if (!panel) return true;
181
182  return panel.classList.contains("is-active") && panel.getAttribute("aria-hidden") !== "true";
183};
184
185const scheduleWhenIdle = (callback) => {
186  if (typeof window.requestIdleCallback === "function") {
187    window.requestIdleCallback(callback, { timeout: 1500 });
188    return;
189  }
190
191  window.setTimeout(callback, 0);
192};
193
194const getChartMetric = (root, propertyName, fallbackValue) => {
195  const value = Number.parseFloat(getComputedStyle(root).getPropertyValue(propertyName));
196  return Number.isFinite(value) ? value : fallbackValue;
197};
198
199const buildMarkup = (title, chartId, pickerId, rangeLabel, showRangeTrigger) => {
200  return `
201    <header class="header">
202      <h3 class="title">${title}</h3>
203      <div class="range-wrap">
204        ${showRangeTrigger ? `<button type="button" class="range-trigger" data-gdm-date-trigger aria-haspopup="dialog" aria-controls="${pickerId}" aria-expanded="false">
205          <span class="range-value" data-gdm-date-label>${rangeLabel}</span>
206        </button>` : ""}
207        <div id="${pickerId}" class="picker" data-gdm-date-picker hidden></div>
208      </div>
209    </header>
210    <div id="${chartId}" class="plot" aria-label="${title}"></div>
211  `;
212};
213
214export default function GDMDateLineChart({ rootSelector = "[data-gdm-date-line-chart]", showRangeTrigger = true } = {}) {
215  const roots = document.querySelectorAll(rootSelector);
216  if (!roots.length) return;
217
218  roots.forEach((root, index) => {
219    const availableDays = Math.max(
220      30,
221      Number.parseInt(root.getAttribute("data-fake-days") || `${DEFAULT_FAKE_DAYS}`, 10) || DEFAULT_FAKE_DAYS,
222    );
223    const defaultDays = Math.max(
224      1,
225      Number.parseInt(root.getAttribute("data-default-days") || "14", 10) || 14,
226    );
227    const title = root.getAttribute("data-chart-title") || "Jobs listed by date published";
228    const chartId = `gdm-date-line-chart-${index}`;
229    const pickerId = `gdm-date-line-picker-${index}`;
230    const datasetEndDate = atNoon(new Date());
231    const dataset = buildDataset(datasetEndDate, availableDays);
232    const datasetStartDate = new Date(dataset[0][0]);
233    const datasetMaxDate = new Date(dataset[dataset.length - 1][0]);
234
235    let range = clampRange(getDefaultRange(defaultDays), datasetStartDate, datasetMaxDate);
236    let chart;
237    let hasRendered = false;
238    let isRendering = false;
239    let panelObserver;
240    let pickerReady = false;
241    let resizeTimer;
242
243    root.innerHTML = buildMarkup(title, chartId, pickerId, formatRangeLabel(range.startDate, range.endDate), showRangeTrigger);
244
245    const trigger = root.querySelector("[data-gdm-date-trigger]");
246    const picker = root.querySelector("[data-gdm-date-picker]");
247    const label = root.querySelector("[data-gdm-date-label]");
248
249    const closePicker = () => {
250      picker.hidden = true;
251      trigger?.setAttribute("aria-expanded", "false");
252    };
253
254    const updateLabel = () => {
255      label.textContent = formatRangeLabel(range.startDate, range.endDate);
256    };
257
258    const renderChart = async () => {
259      const Highcharts = await ensureHighcharts();
260      const points = filterDataset(dataset, range.startDate, range.endDate);
261      const yAxisMax = getYAxisMax(points);
262      const tickPositions = Array.from(
263        { length: Math.max(1, Math.floor(yAxisMax / 200)) },
264        (_, tickIndex) => (tickIndex + 1) * 200,
265      );
266      const tickInterval = getTickInterval(points.length);
267      const useMonthLabels = points.length > 31;
268      const lineWidth = getChartMetric(root, "--gdm-date-line-width", 2);
269      const axisLabelSize = getChartMetric(root, "--gdm-date-axis-label-size", 12);
270      const pointDiameter = getChartMetric(root, "--gdm-date-point-diameter", 9);
271      const pointRadius = pointDiameter / 2;
272      const pointStrokeWidth = getChartMetric(root, "--gdm-date-point-stroke-width", 2);
273
274      if (chart) {
275        chart.destroy();
276      }
277
278      chart = Highcharts.chart(chartId, {
279        chart: {
280          backgroundColor: "transparent",
281          spacing: [24, 8, 0, 0],
282          style: {
283            fontFamily: "inherit",
284          },
285        },
286        title: {
287          text: null,
288        },
289        credits: {
290          enabled: false,
291        },
292        legend: {
293          enabled: false,
294        },
295        tooltip: {
296          enabled: true,
297          useHTML: true,
298          backgroundColor: "transparent",
299          borderWidth: 0,
300          borderRadius: 0,
301          padding: 0,
302          shadow: {
303            color: "rgba(0, 0, 0, 0.18)",
304            offsetX: 0,
305            offsetY: 6,
306            opacity: 0.2,
307            width: 12,
308          },
309          positioner(labelWidth, labelHeight, point) {
310            return {
311              x: this.chart.plotLeft + point.plotX + pointRadius,
312              y: this.chart.plotTop + point.plotY + pointRadius,
313            };
314          },
315          formatter() {
316            const value = Number.isFinite(this.y) ? Math.round(this.y) : 0;
317            return `
318              <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;">
319                <div style="font-size:1.2rem; font-weight:700; letter-spacing:0.02em; margin-bottom:0.35rem;">${formatTooltipDate(this.x)}</div>
320                <div style="font-size:1.6rem; font-weight:500; letter-spacing:0.01em;">${value} Jobs</div>
321              </div>
322            `;
323          },
324        },
325        xAxis: {
326          type: "datetime",
327          tickInterval,
328          lineColor: AXIS_COLOR,
329          lineWidth: 1,
330          tickColor: AXIS_COLOR,
331          tickLength: 16,
332          minPadding: 0.04,
333          maxPadding: 0.04,
334          labels: {
335            y: 32,
336            style: {
337              color: LABEL_COLOR,
338              fontSize: `${axisLabelSize}px`,
339              fontWeight: "400",
340            },
341            formatter() {
342              return Highcharts.dateFormat(useMonthLabels ? "%e %b" : "%e", this.value);
343            },
344          },
345        },
346        yAxis: {
347          title: {
348            text: null,
349          },
350          min: 0,
351          max: yAxisMax,
352          tickPositions,
353          gridLineColor: GRID_COLOR,
354          gridLineDashStyle: "Dash",
355          gridLineWidth: 1,
356          lineColor: AXIS_COLOR,
357          lineWidth: 1,
358          tickLength: 0,
359          labels: {
360            x: -20,
361            style: {
362              color: LABEL_COLOR,
363              fontSize: `${axisLabelSize}px`,
364              fontWeight: "500",
365            },
366          },
367        },
368        plotOptions: {
369          series: {
370            animation: false,
371            states: {
372              inactive: {
373                opacity: 1,
374              },
375            },
376          },
377          line: {
378            lineWidth,
379            color: LINE_COLOR,
380            marker: {
381              enabled: true,
382              radius: pointRadius,
383              fillColor: "#ffffff",
384              lineColor: LINE_COLOR,
385              lineWidth: pointStrokeWidth,
386              states: {
387                hover: {
388                  enabled: true,
389                  radius: pointRadius,
390                  fillColor: "#ffffff",
391                  lineColor: LINE_COLOR,
392                  lineWidth: pointStrokeWidth,
393                },
394              },
395            },
396            states: {
397              hover: {
398                lineWidthPlus: 0,
399              },
400            },
401          },
402        },
403        series: [
404          {
405            type: "line",
406            data: points,
407            enableMouseTracking: true,
408          },
409        ],
410      });
411
412      const series = chart.series?.[0];
413      if (!series) return;
414
415      const isInsidePlot = (normalized) => {
416        return (
417          normalized.chartX >= chart.plotLeft &&
418          normalized.chartX <= chart.plotLeft + chart.plotWidth &&
419          normalized.chartY >= chart.plotTop &&
420          normalized.chartY <= chart.plotTop + chart.plotHeight
421        );
422      };
423
424      const getNearestPoint = (normalized) => {
425        let nearestPoint = null;
426        let nearestDistance = Number.POSITIVE_INFINITY;
427
428        series.points.forEach((point) => {
429          if (!Number.isFinite(point.plotX)) return;
430          const pointChartX = chart.plotLeft + point.plotX;
431          const distance = Math.abs(pointChartX - normalized.chartX);
432
433          if (distance < nearestDistance) {
434            nearestDistance = distance;
435            nearestPoint = point;
436          }
437        });
438
439        return nearestPoint;
440      };
441
442      const handlePointerMove = (event) => {
443        const normalized = chart.pointer.normalize(event);
444        if (!isInsidePlot(normalized)) {
445          chart.pointer.reset();
446          return;
447        }
448
449        const point = getNearestPoint(normalized);
450        if (!point) return;
451
452        point.setState("hover");
453        chart.tooltip.refresh(point);
454      };
455
456      const handlePointerLeave = () => {
457        chart.pointer.reset();
458      };
459
460      chart.container.addEventListener("mousemove", handlePointerMove);
461      chart.container.addEventListener("touchmove", handlePointerMove, { passive: true });
462      chart.container.addEventListener("mouseleave", handlePointerLeave);
463    };
464
465    const ensurePicker = () => {
466      if (pickerReady) return;
467
468      picker.innerHTML = `
469        <div class="picker-grid">
470          <label>
471            <span>Start date</span>
472            <input type="date" data-gdm-date-start>
473          </label>
474          <label>
475            <span>End date</span>
476            <input type="date" data-gdm-date-end>
477          </label>
478        </div>
479        <div class="picker-actions">
480          <button type="button" class="picker-button" data-gdm-date-cancel>Cancel</button>
481          <button type="button" class="picker-button is-primary" data-gdm-date-apply>Apply</button>
482        </div>
483      `;
484
485      const startInput = picker.querySelector("[data-gdm-date-start]");
486      const endInput = picker.querySelector("[data-gdm-date-end]");
487      const cancelButton = picker.querySelector("[data-gdm-date-cancel]");
488      const applyButton = picker.querySelector("[data-gdm-date-apply]");
489
490      const syncInputs = () => {
491        startInput.value = formatInputValue(range.startDate);
492        endInput.value = formatInputValue(range.endDate);
493        startInput.min = formatInputValue(datasetStartDate);
494        startInput.max = endInput.value;
495        startInput.max = endInput.value;
496        endInput.max = formatInputValue(datasetMaxDate);
497        endInput.min = startInput.value;
498      };
499
500      syncInputs();
501
502      startInput.addEventListener("change", () => {
503        endInput.min = startInput.value;
504      });
505
506      endInput.addEventListener("change", () => {
507        startInput.max = endInput.value;
508      });
509
510      cancelButton.addEventListener("click", () => {
511        syncInputs();
512        closePicker();
513      });
514
515      applyButton.addEventListener("click", async () => {
516        const startDate = parseInputValue(startInput.value);
517        const endDate = parseInputValue(endInput.value);
518        if (!startDate || !endDate || startDate.getTime() > endDate.getTime()) return;
519
520        range = clampRange({ startDate, endDate }, datasetStartDate, datasetMaxDate);
521        updateLabel();
522        closePicker();
523
524        if (!hasRendered) {
525          await startRender();
526          return;
527        }
528
529        await renderChart();
530      });
531
532      picker.addEventListener("keydown", (event) => {
533        if (event.key === "Escape") {
534          closePicker();
535          trigger?.focus();
536        }
537
538        if (event.key === "Enter" && event.target.tagName === "INPUT") {
539          event.preventDefault();
540          applyButton.click();
541        }
542      });
543
544      pickerReady = true;
545      picker.syncInputs = syncInputs;
546    };
547
548    const openPicker = () => {
549      ensurePicker();
550      picker.syncInputs?.();
551      picker.hidden = false;
552      trigger?.setAttribute("aria-expanded", "true");
553
554      const startInput = picker.querySelector("[data-gdm-date-start]");
555      if (typeof startInput?.showPicker === "function") {
556        startInput.showPicker();
557      } else {
558        startInput?.focus();
559      }
560    };
561
562    const startRender = async () => {
563      if (hasRendered || isRendering) return;
564
565      isRendering = true;
566
567      try {
568        await renderChart();
569        hasRendered = true;
570        panelObserver?.disconnect();
571      } catch (error) {
572        console.error("GDMDateLineChart failed to initialize", error);
573      } finally {
574        isRendering = false;
575      }
576    };
577
578    if (trigger) {
579      trigger.addEventListener("click", (event) => {
580        event.stopPropagation();
581
582        if (picker.hidden) {
583          openPicker();
584          return;
585        }
586
587        closePicker();
588      });
589    }
590
591    picker.addEventListener("click", (event) => {
592      event.stopPropagation();
593    });
594
595    document.addEventListener("click", (event) => {
596      if (!root.contains(event.target)) {
597        closePicker();
598      }
599    });
600
601    window.addEventListener("resize", () => {
602      if (!hasRendered || !isRootActive(root)) return;
603
604      window.clearTimeout(resizeTimer);
605      resizeTimer = window.setTimeout(() => {
606        renderChart();
607      }, 120);
608    });
609
610    if (isRootActive(root)) {
611      scheduleWhenIdle(() => {
612        startRender();
613      });
614    } else {
615      const panel = root.closest("[data-toggle-content-key]");
616      if (panel && typeof MutationObserver === "function") {
617        panelObserver = new MutationObserver(() => {
618          if (!isRootActive(root)) return;
619
620          scheduleWhenIdle(() => {
621            startRender();
622          });
623        });
624
625        panelObserver.observe(panel, {
626          attributes: true,
627          attributeFilter: ["class", "aria-hidden"],
628        });
629      }
630    }
631  });
632}

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.