223 lines
6.5 KiB
JavaScript
223 lines
6.5 KiB
JavaScript
(() => {
|
|
let qs = new URLSearchParams(window.location.search);
|
|
|
|
const termInput = document.getElementById("term");
|
|
const channelSel = document.getElementById("channel");
|
|
const intervalSel = document.getElementById("interval");
|
|
const startInput = document.getElementById("start");
|
|
const endInput = document.getElementById("end");
|
|
const runBtn = document.getElementById("runBtn");
|
|
const summaryDiv = document.getElementById("summary");
|
|
const chartDiv = document.getElementById("chart");
|
|
|
|
function parseParams() {
|
|
return {
|
|
term: qs.get("term") || "",
|
|
channel: qs.get("channel_id") || "all",
|
|
interval: qs.get("interval") || "month",
|
|
start: qs.get("start") || "",
|
|
end: qs.get("end") || "",
|
|
};
|
|
}
|
|
|
|
function setFormFromParams() {
|
|
const params = parseParams();
|
|
termInput.value = params.term;
|
|
intervalSel.value = params.interval;
|
|
startInput.value = params.start;
|
|
endInput.value = params.end;
|
|
return params;
|
|
}
|
|
|
|
function updateUrl(params) {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set("term", params.term);
|
|
url.searchParams.set("channel_id", params.channel);
|
|
url.searchParams.set("interval", params.interval);
|
|
if (params.start) url.searchParams.set("start", params.start);
|
|
else url.searchParams.delete("start");
|
|
if (params.end) url.searchParams.set("end", params.end);
|
|
else url.searchParams.delete("end");
|
|
history.pushState({}, "", url.toString());
|
|
qs = new URLSearchParams(url.search);
|
|
}
|
|
|
|
async function loadChannels(initialValue) {
|
|
try {
|
|
const res = await fetch("/api/channels");
|
|
const data = await res.json();
|
|
data.forEach((item) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = item.Id;
|
|
opt.textContent = `${item.Name} (${item.Count})`;
|
|
channelSel.appendChild(opt);
|
|
});
|
|
} catch (err) {
|
|
console.error("Failed to load channels", err);
|
|
}
|
|
channelSel.value = initialValue || "all";
|
|
}
|
|
|
|
function drawChart(data) {
|
|
chartDiv.innerHTML = "";
|
|
if (!data.length) {
|
|
const msg = document.createElement("div");
|
|
msg.className = "muted";
|
|
msg.textContent = "No matching documents for this term.";
|
|
chartDiv.appendChild(msg);
|
|
return;
|
|
}
|
|
|
|
const parsed = data
|
|
.map((d) => ({
|
|
date: d3.isoParse(d.date) || new Date(d.date),
|
|
value: d.count,
|
|
}))
|
|
.filter((d) => d.date instanceof Date && !Number.isNaN(d.date.valueOf()));
|
|
|
|
if (!parsed.length) {
|
|
const msg = document.createElement("div");
|
|
msg.className = "muted";
|
|
msg.textContent = "Unable to parse dates for this series.";
|
|
chartDiv.appendChild(msg);
|
|
return;
|
|
}
|
|
|
|
const margin = { top: 20, right: 30, bottom: 40, left: 56 };
|
|
const fullWidth = chartDiv.clientWidth || 900;
|
|
const fullHeight = 360;
|
|
const width = fullWidth - margin.left - margin.right;
|
|
const height = fullHeight - margin.top - margin.bottom;
|
|
|
|
const svg = d3
|
|
.select(chartDiv)
|
|
.append("svg")
|
|
.attr("width", fullWidth)
|
|
.attr("height", fullHeight);
|
|
|
|
const g = svg
|
|
.append("g")
|
|
.attr("transform", `translate(${margin.left},${margin.top})`);
|
|
|
|
const x = d3
|
|
.scaleTime()
|
|
.domain(d3.extent(parsed, (d) => d.date))
|
|
.range([0, width]);
|
|
|
|
const y = d3
|
|
.scaleLinear()
|
|
.domain([0, d3.max(parsed, (d) => d.value) || 0])
|
|
.nice()
|
|
.range([height, 0]);
|
|
|
|
const xAxis = d3.axisBottom(x).ticks(6).tickFormat(d3.timeFormat("%Y-%m-%d"));
|
|
const yAxis = d3.axisLeft(y).ticks(6);
|
|
|
|
g.append("g")
|
|
.attr("class", "axis")
|
|
.attr("transform", `translate(0,${height})`)
|
|
.call(xAxis)
|
|
.selectAll("text")
|
|
.attr("text-anchor", "end")
|
|
.attr("transform", "rotate(-35)")
|
|
.attr("dx", "-0.8em")
|
|
.attr("dy", "0.15em");
|
|
|
|
g.append("g").attr("class", "axis").call(yAxis);
|
|
|
|
const line = d3
|
|
.line()
|
|
.x((d) => x(d.date))
|
|
.y((d) => y(d.value));
|
|
|
|
g.append("path")
|
|
.datum(parsed)
|
|
.attr("class", "line")
|
|
.attr("d", line);
|
|
|
|
g.selectAll(".dot")
|
|
.data(parsed)
|
|
.enter()
|
|
.append("circle")
|
|
.attr("class", "dot")
|
|
.attr("r", 3)
|
|
.attr("cx", (d) => x(d.date))
|
|
.attr("cy", (d) => y(d.value))
|
|
.append("title")
|
|
.text((d) => `${d3.timeFormat("%Y-%m-%d")(d.date)}: ${d.value}`);
|
|
}
|
|
|
|
async function runFrequency(pushState = true) {
|
|
const term = termInput.value.trim();
|
|
if (!term) {
|
|
summaryDiv.textContent = "Enter a term to begin.";
|
|
chartDiv.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
const params = {
|
|
term,
|
|
channel: channelSel.value,
|
|
interval: intervalSel.value,
|
|
start: startInput.value,
|
|
end: endInput.value,
|
|
};
|
|
|
|
if (pushState) updateUrl(params);
|
|
|
|
const search = new URLSearchParams();
|
|
search.set("term", term);
|
|
if (params.channel && params.channel !== "all") {
|
|
search.set("channel_id", params.channel);
|
|
}
|
|
search.set("interval", params.interval);
|
|
if (params.start) search.set("start", params.start);
|
|
if (params.end) search.set("end", params.end);
|
|
|
|
summaryDiv.textContent = "Loading…";
|
|
chartDiv.innerHTML = "";
|
|
|
|
try {
|
|
const res = await fetch(`/api/frequency?${search.toString()}`);
|
|
if (!res.ok) {
|
|
throw new Error(`Request failed: ${res.status}`);
|
|
}
|
|
const payload = await res.json();
|
|
const total = payload.totalResults || 0;
|
|
summaryDiv.textContent = `Matches: ${total.toLocaleString()} • Buckets: ${
|
|
(payload.buckets || []).length
|
|
} • Interval: ${payload.interval}`;
|
|
drawChart(payload.buckets || []);
|
|
} catch (err) {
|
|
console.error(err);
|
|
summaryDiv.textContent = "Failed to load data.";
|
|
}
|
|
}
|
|
|
|
runBtn.addEventListener("click", () => runFrequency());
|
|
termInput.addEventListener("keypress", (e) => {
|
|
if (e.key === "Enter") runFrequency();
|
|
});
|
|
intervalSel.addEventListener("change", () => runFrequency());
|
|
channelSel.addEventListener("change", () => runFrequency());
|
|
startInput.addEventListener("change", () => runFrequency());
|
|
endInput.addEventListener("change", () => runFrequency());
|
|
|
|
window.addEventListener("popstate", () => {
|
|
qs = new URLSearchParams(window.location.search);
|
|
const params = setFormFromParams();
|
|
channelSel.value = params.channel;
|
|
runFrequency(false);
|
|
});
|
|
|
|
const initialParams = setFormFromParams();
|
|
loadChannels(initialParams.channel).then(() => {
|
|
if (initialParams.term) {
|
|
runFrequency(false);
|
|
} else {
|
|
summaryDiv.textContent = "Enter a term to begin.";
|
|
}
|
|
});
|
|
})();
|
|
|