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.