Arkansas Educator Job Postings

categoryGroup = (cat) => {
  if (cat === "Teacher") return "Teacher";
  if (cat === "Substitute Teacher") return "Substitute";
  if (cat === "Paraeducator (SPED)" || cat === "Paraeducator (Other)") return "Paras";
  if (cat === "Other Administrator" || cat === "Instructional Support" || cat === "Principal/Asst. Principal") return "Admin/Support";
  if (cat === "Athletic Coach") return "Athletic Coach";
  if (cat === "Support Services") return "Support Services";
  return "Other";
}
categoryData = d3.rollups(filtered, v => v.length, d => categoryGroup(d.category))
  .map(([category, count]) => ({category, count}))
subjectGroup = (subj) => {
  if (subj === "Special Education") return "Special Education";
  if (subj === "Multi-Lingual Learners") return "Multi-Lingual Learners";
  if (subj === "Science") return "Science";
  if (subj === "Mathematics") return "Mathematics";
  if (subj === "English Language Arts") return "ELA";
  if (subj === "Social Studies") return "Social Studies";
  if (subj === "Elementary Education") return "Elementary";
  return "Other";
}

subjectData = d3.rollups(
  filtered.filter(d => d.subject !== "(none)"),
  v => v.length,
  d => subjectGroup(d.subject)
).map(([subject, count]) => ({subject, count}))
{
  const viewW = window.innerWidth;
  const isMobile = viewW <= 768;
  const mainW = isMobile ? viewW - 32 : viewW - 280 - 48;
  const gap = 12;
  const catW = Math.max(200, isMobile ? mainW : Math.floor((mainW - gap) / 2));
  const subjW = catW;
  const catH = Math.round(catW * 0.55);
  const subjH = Math.round(subjW * 0.55);

  return html`<div>
    <div class="date-range">Posts scraped between ${date_start} and ${date_end}</div>
    <div class="charts-row">
      <div class="chart-card">
        <div class="chart-title">Posts by Category</div>
        ${Plot.plot({
          marginLeft: Math.min(140, Math.round(catW * 0.3)),
          marginTop: 8,
          marginBottom: 28,
          width: catW,
          height: catH,
          style: {fontSize: "12px", fontFamily: "Source Sans 3, Source Sans Pro, sans-serif", background: "transparent"},
          x: {label: "Posts"},
          y: {label: null},
          color: {
            range: ["#1e40af", "#2563eb", "#3b82f6", "#6366f1", "#818cf8", "#a78bfa", "#c4b5fd"]
          },
          marks: [
            Plot.gridX({stroke: "#e2e8f0", strokeOpacity: 0.5}),
            Plot.barX(categoryData, {
              x: "count", y: "category", fill: "category",
              sort: {y: "-x"}, rx: 3
            }),
            Plot.tip(categoryData, Plot.pointerY({
              x: "count", y: "category",
              title: d => `Category: ${d.category}\nCount: ${d.count.toLocaleString()}`
            }))
          ]
        })}
      </div>
      <div class="chart-card">
        <div class="chart-title">Teacher Posts by Subject</div>
        ${Plot.plot({
          marginLeft: Math.min(160, Math.round(subjW * 0.35)),
          marginTop: 8,
          marginBottom: 28,
          width: subjW,
          height: subjH,
          style: {fontSize: "12px", fontFamily: "Source Sans 3, Source Sans Pro, sans-serif", background: "transparent"},
          x: {label: "Posts"},
          y: {label: null},
          marks: [
            Plot.gridX({stroke: "#e2e8f0", strokeOpacity: 0.5}),
            Plot.barX(subjectData, {
              x: "count", y: "subject", fill: "#6366f1",
              sort: {y: "-x"}, rx: 3
            }),
            Plot.tip(subjectData, Plot.pointerY({
              x: "count", y: "subject",
              title: d => `Subject: ${d.subject}\nCount: ${d.count.toLocaleString()}`
            }))
          ]
        })}
      </div>
    </div>
  </div>`;
}
districtData = d3.rollups(filtered, v => {
  const teacher = ["Teacher"];
  const substitute = ["Substitute Teacher"];
  const paras = ["Paraeducator (SPED)", "Paraeducator (Other)"];
  const admin = ["Other Administrator", "Instructional Support", "Principal/Asst. Principal"];
  const athletic = ["Athletic Coach"];
  const support = ["Support Services"];
  const allSpecific = [...teacher, ...substitute, ...paras, ...admin, ...athletic, ...support];
  return {
    All: v.length,
    Teacher: v.filter(d => teacher.includes(d.category)).length,
    Substitute: v.filter(d => substitute.includes(d.category)).length,
    Paras: v.filter(d => paras.includes(d.category)).length,
    "Admin/Support": v.filter(d => admin.includes(d.category)).length,
    "Athletic Coach": v.filter(d => athletic.includes(d.category)).length,
    "Support Services": v.filter(d => support.includes(d.category)).length,
    Other: v.filter(d => !allSpecific.includes(d.category)).length
  };
}, d => d.lea_name)
  .map(([name, counts]) => ({District: name, ...counts}))
  .sort((a, b) => b.All - a.All)
html`<div class="table-scroll"><table class="district-table">
  <thead>
    <tr>
      <th>District</th>
      <th class="num">All</th>
      <th class="num">Teacher</th>
      <th class="num">Substitute</th>
      <th class="num">Paras</th>
      <th class="num">Admin/Support</th>
      <th class="num">Athletic Coach</th>
      <th class="num">Support Services</th>
      <th class="num">Other</th>
    </tr>
  </thead>
  <tbody>
    ${districtData.map(d => html`<tr>
      <td class="district-name" title="${d.District}">${d.District}</td>
      <td class="num">${d.All.toLocaleString()}</td>
      <td class="num">${d.Teacher.toLocaleString()}</td>
      <td class="num">${d.Substitute.toLocaleString()}</td>
      <td class="num">${d.Paras.toLocaleString()}</td>
      <td class="num">${d["Admin/Support"].toLocaleString()}</td>
      <td class="num">${d["Athletic Coach"].toLocaleString()}</td>
      <td class="num">${d["Support Services"].toLocaleString()}</td>
      <td class="num">${d.Other.toLocaleString()}</td>
    </tr>`)}
  </tbody>
</table></div>`
data = transpose(raw_data)
function dropdownFilter(options, {label = ""} = {}) {
  let selected = new Set(options);

  const badge = html`<span class="filter-badge">All</span>`;
  const chevron = html`<span class="filter-chevron">&#9662;</span>`;
  const searchInput = html`<input type="text" class="filter-search" placeholder="Search...">`;

  const selectAllCb = html`<input type="checkbox" checked>`;
  const optionEls = options.map(opt => {
    const cb = html`<input type="checkbox" checked>`;
    const lbl = html`<label class="filter-option">${cb} ${opt}</label>`;
    return {cb, lbl, value: opt};
  });

  const container = html`<div class="filter-dropdown">
    <div class="filter-label">${label}</div>
    <details class="filter-details">
      <summary>${badge} ${chevron}</summary>
      <div class="filter-panel">
        <div class="filter-search-wrap">${searchInput}</div>
        <label class="filter-option select-all">${selectAllCb} Select all</label>
        <hr class="filter-divider">
        <div class="filter-scroll">
          ${optionEls.map(o => o.lbl)}
        </div>
      </div>
    </details>
  </div>`;

  searchInput.oninput = () => {
    const q = searchInput.value.toLowerCase();
    optionEls.forEach(({lbl, value}) => {
      lbl.style.display = value.toLowerCase().includes(q) ? "" : "none";
    });
  };

  container.querySelector(".filter-details").addEventListener("toggle", (e) => {
    if (!e.target.open) {
      searchInput.value = "";
      optionEls.forEach(({lbl}) => lbl.style.display = "");
    }
  });

  function update() {
    badge.textContent = selected.size === options.length ? "All"
      : selected.size === 0 ? "None"
      : `${selected.size} of ${options.length}`;
    container.value = [...selected];
    container.dispatchEvent(new Event("input", {bubbles: true}));
  }

  selectAllCb.onchange = () => {
    const checked = selectAllCb.checked;
    selected = checked ? new Set(options) : new Set();
    optionEls.forEach(o => o.cb.checked = checked);
    update();
  };

  optionEls.forEach(({cb, value}) => {
    cb.onchange = () => {
      cb.checked ? selected.add(value) : selected.delete(value);
      selectAllCb.checked = selected.size === options.length;
      selectAllCb.indeterminate = selected.size > 0 && selected.size < options.length;
      update();
    };
  });

  container.value = [...selected];
  return container;
}
{
  if (!window._filterClickHandler) {
    document.addEventListener("click", (e) => {
      document.querySelectorAll(".filter-details[open]").forEach(d => {
        if (!d.contains(e.target)) d.open = false;
      });
    });
    window._filterClickHandler = true;
  }
}
viewof selected_lea = dropdownFilter(
  [...new Set(data.map(d => d.lea_name))].sort(),
  {label: "District"}
)

viewof selected_category = dropdownFilter(
  [...new Set(data.map(d => d.category))].sort(),
  {label: "Category"}
)

viewof selected_subject = dropdownFilter(
  [...new Set(data.map(d => d.subject))].sort(),
  {label: "Subject"}
)
filtered = data.filter(d =>
  selected_lea.includes(d.lea_name) &&
  selected_category.includes(d.category) &&
  selected_subject.includes(d.subject)
)
{
  const csvCols = Object.keys(data[0]);
  const escape = v => v == null ? "" : typeof v === "string" && /[,"\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
  const csvRows = [csvCols.join(","), ...data.map(d => csvCols.map(c => escape(d[c])).join(","))].join("\n");
  const blob = new Blob([csvRows], {type: "text/csv"});
  const url = URL.createObjectURL(blob);

  const aboutLink = html`<span class="about-link">&#x2139;&#xFE0E; About these data</span>`;
  aboutLink.onclick = () => {
    const overlay = html`<div class="about-overlay">
      <div class="about-modal">
        <h3>About These Data</h3>
        <p>This dashboard shows educator job postings scraped from public school district job boards across Arkansas. Not all districts are covered; coverage depends on which job board platforms are supported by our scrapers. Postings are collected daily and classified by an LLM into categories, subjects, and grade levels.</p>
        <p>Only postings active within the last 7 days are shown. Use the filters to narrow by district, category, or subject. The table and charts update to reflect your selections.</p>
        <p style="margin-top: 0.75rem; font-size: 0.75rem; color: #64748b;"><strong>Suggested citation:</strong> Camp, A. M. (2026). Arkansas Educator Job Postings Dashboard. https://job-postings.andrewmcamp.com/. Retrieved ${new Date().toLocaleDateString("en-US", {month: "long", day: "numeric", year: "numeric"})}.</p>
        <p style="margin-top: 0.5rem; font-size: 0.8125rem;"><a href="mailto:andrew_camp@brown.edu" style="color: var(--accent);">Interested in using these data? Get in touch &rarr;</a></p>
        <button class="about-modal-close">&times;</button>
      </div>
    </div>`;
    overlay.querySelector(".about-modal-close").onclick = () => overlay.remove();
    overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
    document.body.appendChild(overlay);
  };

  return html`<div class="sidebar-count">
    <div class="count-number">${filtered.length.toLocaleString()}</div>
    <div class="count-label">job posts selected</div>
    <div>${aboutLink}</div>
    <div><a href="${url}" download="educator_job_postings_ar_${date_file}.csv" class="download-btn">&#8681; Download CSV</a></div>
  </div>`;
}