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.