PageSourceSearch

https://www.investmentmonitor.ai/wp-content/themes/goodlife-wp-B2B/assets/js/modules/GDMRemoteStatusChart.js

js investmentmonitor.ai collected 2026-10-02 02:41:38 UTC 10,078 bytes, 291 lines download raw bytes

1const SEGMENTS = [
2  { key: "onsite",      label: "Onsite",       color: "#72c18f" },
3  { key: "remote",      label: "Remote",       color: "#f5cf4e" },
4  { key: "notSpecific", label: "Not Specific", color: "#62b4ea" },
5];
6
7const PALETTE = [
8  "#4b7fd1", "#72c18f", "#f5cf4e", "#62b4ea", "#e48d33",
9  "#a259d9", "#e05c5c", "#38b2ac", "#f6a623", "#7b8fa6",
10  "#56c4a0", "#d4736a", "#6fa8dc", "#c4a35a", "#8e7dbf",
11];
12
13const CHART_CONFIG = {
14  half: {
15    centerX: 180,
16    centerY: 180,
17    endAngle: 90,
18    gapPx: 4,
19    radius: 124,
20    startAngle: -90,
21    totalClassName: "donut-total donut-total--half",
22    viewBox: "0 0 360 220",
23  },
24  full: {
25    centerX: 180,
26    centerY: 180,
27    endAngle: 270,
28    gapPx: 2,
29    radius: 124,
30    startAngle: -90,
31    totalClassName: "donut-total donut-total--full",
32    viewBox: "0 0 360 360",
33  },
34};
35
36const getValue = (root, attributeName) => {
37  const raw = Number.parseInt(root.getAttribute(attributeName) || "0", 10);
38  return Number.isFinite(raw) ? Math.max(0, raw) : 0;
39};
40
41const normaliseItem = (item, index) => ({
42  color: item?.color || SEGMENTS[index % SEGMENTS.length]?.color || "#62b4ea",
43  label: item?.label || `Item ${index + 1}`,
44  value: Number.isFinite(Number(item?.value)) ? Math.max(0, Number(item.value)) : 0,
45});
46
47const escapeHtml = (value) =>
48  String(value)
49    .replace(/&/g, "&")
50    .replace(/</g, "&lt;")
51    .replace(/>/g, "&gt;")
52    .replace(/\"/g, "&quot;")
53    .replace(/'/g, "&#39;");
54
55const parseJsonConfig = (root) => {
56  const dataNode = root.querySelector(".gdm-chart__data");
57  if (!dataNode) return null;
58
59  try {
60    return JSON.parse(dataNode.textContent || "{}");
61  } catch (error) {
62    console.error("Invalid donut chart config", error);
63    return null;
64  }
65};
66
67const polarToCartesian = (centerX, centerY, radius, angleInDegrees) => {
68  const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180;
69
70  return {
71    x: centerX + radius * Math.cos(angleInRadians),
72    y: centerY + radius * Math.sin(angleInRadians),
73  };
74};
75
76const describeArc = (centerX, centerY, radius, startAngle, endAngle) => {
77  const start = polarToCartesian(centerX, centerY, radius, endAngle);
78  const end = polarToCartesian(centerX, centerY, radius, startAngle);
79  const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
80
81  return [
82    "M",
83    start.x,
84    start.y,
85    "A",
86    radius,
87    radius,
88    0,
89    largeArcFlag,
90    0,
91    end.x,
92    end.y,
93  ].join(" ");
94};
95
96const buildArcMarkup = (items, variantName, totalLabel, total) => {
97  const variant = CHART_CONFIG[variantName] || CHART_CONFIG.half;
98  const totalAngle = variant.endAngle - variant.startAngle;
99  const gapAngle = (variant.gapPx / variant.radius) * (180 / Math.PI);
100  const chartTotal = items.reduce((sum, item) => sum + item.value, 0) || 1;
101  const renderAllBoundaries = variantName === "full";
102
103  let currentAngle = variant.startAngle;
104  const paths = items
105    .filter((item) => item.value > 0)
106    .map((item, index, visibleItems) => {
107      const segmentAngle = (item.value / chartTotal) * totalAngle;
108      const hasLeadingGap = renderAllBoundaries || index > 0;
109      const hasTrailingGap = renderAllBoundaries || index < visibleItems.length - 1;
110      const safeGapAngle = Math.min(gapAngle, Math.max(segmentAngle - 0.1, 0));
111      const halfGapAngle = safeGapAngle / 2;
112      const segmentStart = currentAngle + (hasLeadingGap ? halfGapAngle : 0);
113      const segmentEnd = currentAngle + segmentAngle - (hasTrailingGap ? halfGapAngle : 0);
114
115      currentAngle += segmentAngle;
116
117      if (segmentEnd <= segmentStart) {
118        return "";
119      }
120
121      return `<path d="${describeArc(variant.centerX, variant.centerY, variant.radius, segmentStart, segmentEnd)}" stroke="${escapeHtml(item.color)}" />`;
122    })
123    .join("");
124
125  const LEGEND_LIMIT = 20;
126  const allRows = items.map(
127    (item, index) => `
128      <div class="donut-legend-row${index >= LEGEND_LIMIT ? " donut-legend-row--hidden" : ""}">
129        <span class="donut-label-wrap">
130          <span class="donut-dot" style="background:${escapeHtml(item.color)}"></span>
131          <span class="donut-label">${escapeHtml(item.label)}</span>
132        </span>
133        <span class="donut-value">${item.value.toLocaleString('en-US')}</span>
134      </div>
135    `,
136  ).join("");
137
138  const hasMore = items.length > LEGEND_LIMIT;
139  const toggleBtn = hasMore
140    ? `<button class="donut-legend-toggle" type="button" data-show-more="false" style="display:none;">Show ${items.length - LEGEND_LIMIT} more</button>`
141    : "";
142
143  return `
144    <div class="donut-figure donut-figure--${variantName}">
145      <svg class="donut-svg" viewBox="${variant.viewBox}" aria-hidden="true" focusable="false">
146        <g class="donut-arcs">${paths}</g>
147      </svg>
148      <div class="${variant.totalClassName}">
149        <span class="donut-total-label">${escapeHtml(totalLabel)}</span>
150        <strong class="donut-total-value">${total.toLocaleString('en-US')}</strong>
151      </div>
152    </div>
153    <div class="donut-legend${variantName === "full" ? " donut-legend--scroll" : ""}">${allRows}${toggleBtn}</div>
154  `;
155};
156
157const getChartConfig = (root) => {
158  const config = parseJsonConfig(root);
159
160  if (config?.items?.length) {
161    const items = config.items.map(normaliseItem);
162    const total = Number.isFinite(Number(config.total))
163      ? Math.max(0, Number(config.total))
164      : items.reduce((sum, item) => sum + item.value, 0);
165
166    return {
167      items,
168      legendLabel: config.legendLabel || `${config.title || "Chart"} breakdown`,
169      title: config.title || root.getAttribute("data-chart-title") || "Chart",
170      total,
171      totalLabel: config.totalLabel || "Total Jobs:",
172      variant: config.variant === "full" ? "full" : "half",
173    };
174  }
175
176  const items = [
177    { ...SEGMENTS[0], value: getValue(root, "data-onsite") },
178    { ...SEGMENTS[1], value: getValue(root, "data-remote") },
179    { ...SEGMENTS[2], value: getValue(root, "data-not-specific") },
180  ].map(normaliseItem);
181
182  return {
183    items,
184    legendLabel: root.getAttribute("data-chart-legend-label") || "Jobs by remote status breakdown",
185    title: root.getAttribute("data-chart-title") || "Jobs by remote status",
186    total: items.reduce((sum, item) => sum + item.value, 0),
187    totalLabel: root.getAttribute("data-total-label") || "Total Jobs:",
188    variant: root.getAttribute("data-chart-variant") === "full" ? "full" : "half",
189  };
190};
191
192const renderChart = (root, items, legendLabel, title, total, totalLabel, variant) => {
193  root.setAttribute("data-chart-variant", variant);
194  root.innerHTML = `
195    <header class="header center">
196      <h3 class="title">${escapeHtml(title)}</h3>
197    </header>
198    <div class="donut-chart" aria-label="${escapeHtml(legendLabel)}">
199      ${buildArcMarkup(items, variant, totalLabel, total)}
200    </div>
201  `;
202};
203
204export default function GDMRemoteStatusChart({ rootSelector = "[data-gdm-remote-status-chart], [data-gdm-donut-chart]" } = {}) {
205  const roots = document.querySelectorAll(rootSelector);
206  if (!roots.length) return;
207
208  const instances = [];
209
210  roots.forEach((root) => {
211    const originalTitle = root.querySelector(".title")?.textContent?.trim() || null;
212    const initialConfig = getChartConfig(root);
213    if (originalTitle && initialConfig.title === "Jobs by remote status") {
214      initialConfig.title = originalTitle;
215    }
216
217    // Render with initial config (may be empty if no static data)
218    renderChart(
219      root,
220      initialConfig.items,
221      initialConfig.legendLabel,
222      initialConfig.title,
223      initialConfig.total,
224      initialConfig.totalLabel,
225      initialConfig.variant,
226    );
227
228    const instance = {
229      root,
230      /**
231       * Re-render the chart with live data from the analytics AJAX response.
232       * @param {Array<{label: string, value: number}>} liveItems
233       */
234      setData(liveItems) {
235        if (!Array.isArray(liveItems) || !liveItems.length) return;
236
237        const variant = root.getAttribute("data-chart-variant") === "full" ? "full" : "half";
238        const title = initialConfig.title;
239        const legendLabel = `${title} breakdown`;
240        const totalLabel = "Total Jobs:";
241
242        const items = liveItems.map((item, index) => ({
243          color: PALETTE[index % PALETTE.length],
244          label: item.label || `Item ${index + 1}`,
245          value: Math.max(0, Number(item.value) || 0),
246        }));
247
248        // For remote-status (half variant) keep the fixed 3-color SEGMENTS mapping
249        const isRemote = variant === "half";
250        const coloredItems = isRemote
251          ? liveItems.map((item, index) => ({
252              color: SEGMENTS[index % SEGMENTS.length]?.color || "#62b4ea",
253              label: item.label || `Item ${index + 1}`,
254              value: Math.max(0, Number(item.value) || 0),
255            }))
256          : items;
257
258        const total = coloredItems.reduce((sum, item) => sum + item.value, 0);
259
260        const donutContainer = root.querySelector(".donut-chart");
261        if (donutContainer) {
262          donutContainer.innerHTML = buildArcMarkup(coloredItems, variant, totalLabel, total);
263        } else {
264          renderChart(root, coloredItems, legendLabel, title, total, totalLabel, variant);
265        }
266      },
267    };
268
269    instances.push(instance);
270  });
271
272  // Expose so search-view.php inline script can call:
273  //   window.GDMRemoteStatusChartInstances[0].setData(analyticsData.remote_status)
274  window.GDMRemoteStatusChartInstances = instances;
275
276  // Toggle "show more / show less" for legend rows beyond 20
277  document.addEventListener("click", (e) => {
278    const btn = e.target.closest(".donut-legend-toggle");
279    if (!btn) return;
280    const legend = btn.closest(".donut-legend");
281    const hidden = legend.querySelectorAll(".donut-legend-row--hidden");
282    const isExpanded = btn.dataset.showMore === "false";
283    hidden.forEach((row) => row.classList.toggle("donut-legend-row--hidden", isExpanded));
284    btn.dataset.showMore = isExpanded ? "true" : "false";
285    btn.textContent = isExpanded
286      ? `Show ${hidden.length} more`
287      : "Show less";
288  });
289
290  return instances;
291}

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.