TLC-Search/static/frequency.js
2025-11-02 01:14:36 -04:00

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.";
}
});
})();