757 lines
33 KiB
HTML
757 lines
33 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Preparedness Survey — Quick Form</title>
|
|
<style>
|
|
body {
|
|
font-family: system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
|
margin: 28px;
|
|
background: #f7fafc
|
|
}
|
|
|
|
.container {
|
|
max-width: 980px;
|
|
margin: 0 auto;
|
|
background: #fff;
|
|
padding: 28px;
|
|
border-radius: 10px;
|
|
box-shadow: 0 6px 20px rgba(2, 6, 23, .08)
|
|
}
|
|
|
|
h1 {
|
|
margin-top: 0;
|
|
margin-bottom: 18px;
|
|
font-size: 28px;
|
|
}
|
|
|
|
.row {
|
|
display: flex;
|
|
gap: 16px;
|
|
margin-bottom: 14px
|
|
}
|
|
|
|
label {
|
|
display: block;
|
|
font-weight: 600;
|
|
margin-bottom: 4px
|
|
}
|
|
|
|
input[type=text],
|
|
select,
|
|
textarea,
|
|
input[type=number] {
|
|
width: 100%;
|
|
padding: 12px;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.grid {
|
|
display: block;
|
|
margin-top: 18px;
|
|
margin-bottom: 18px;
|
|
}
|
|
|
|
/* form rows: labels above inputs for cleaner reading and to avoid overlap */
|
|
.form-row {
|
|
display: block;
|
|
padding: 12px 0;
|
|
border-bottom: 1px solid #eef2f6;
|
|
margin: 0;
|
|
}
|
|
|
|
/* label shown above the input area */
|
|
.form-row>label,
|
|
.label-top {
|
|
display: block;
|
|
width: 100%;
|
|
margin: 0 0 8px 0;
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
color: #111827;
|
|
}
|
|
|
|
.form-row .input-wrap {
|
|
width: 100%;
|
|
margin: 0;
|
|
}
|
|
|
|
/* keep Likert rows visually compact with the question label above */
|
|
.form-row.likert-row {
|
|
display: block
|
|
}
|
|
|
|
.form-row.likert-row .input-wrap.likert {
|
|
display: flex;
|
|
gap: 12px;
|
|
flex-wrap: wrap
|
|
}
|
|
|
|
@media (max-width:700px) {
|
|
.form-row {
|
|
flex-direction: column;
|
|
align-items: stretch
|
|
}
|
|
|
|
.form-row label {
|
|
width: 100%
|
|
}
|
|
|
|
.form-row .input-wrap {
|
|
width: 100%
|
|
}
|
|
}
|
|
|
|
.likert {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
flex-wrap: wrap
|
|
}
|
|
|
|
/* each option is an inline radio + text so the options form a horizontal row */
|
|
.likert label {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-weight: 400;
|
|
white-space: nowrap;
|
|
padding: 4px 6px;
|
|
border-radius: 6px
|
|
}
|
|
|
|
.likert input[type=radio] {
|
|
margin: 0 6px 0 0
|
|
}
|
|
|
|
/* Normalize likert groups so all questions display consistently */
|
|
.input-wrap.likert {
|
|
display: flex;
|
|
}
|
|
|
|
.input-wrap.likert .likert,
|
|
.input-wrap.likert {
|
|
width: 100%;
|
|
align-items: center
|
|
}
|
|
|
|
.input-wrap.likert .likert {
|
|
justify-content: flex-start;
|
|
gap: 14px;
|
|
}
|
|
|
|
.input-wrap.likert label {
|
|
padding: 6px 10px;
|
|
border-radius: 8px;
|
|
background: transparent
|
|
}
|
|
|
|
.input-wrap.likert label:hover {
|
|
background: rgba(2, 6, 23, 0.03)
|
|
}
|
|
|
|
.input-wrap.likert input[type=radio] {
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
/* toggle switch for binary yes/no selects */
|
|
.toggle-switch {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
cursor: pointer
|
|
}
|
|
|
|
.toggle-switch input {
|
|
display: none
|
|
}
|
|
|
|
.toggle-switch .slider {
|
|
width: 48px;
|
|
height: 26px;
|
|
background: #e5e7eb;
|
|
border-radius: 999px;
|
|
position: relative;
|
|
transition: background .18s ease
|
|
}
|
|
|
|
.toggle-switch .slider::after {
|
|
content: '';
|
|
position: absolute;
|
|
left: 4px;
|
|
top: 4px;
|
|
width: 18px;
|
|
height: 18px;
|
|
background: #fff;
|
|
border-radius: 50%;
|
|
transition: transform .18s ease
|
|
}
|
|
|
|
.toggle-switch input:checked+.slider {
|
|
background: #10b981
|
|
}
|
|
|
|
.toggle-switch input:checked+.slider::after {
|
|
transform: translateX(22px)
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
gap: 12px;
|
|
margin-top: 20px
|
|
}
|
|
|
|
button {
|
|
padding: 10px 14px;
|
|
border-radius: 8px;
|
|
border: 0;
|
|
background: #0069ff;
|
|
color: #fff;
|
|
font-weight: 600;
|
|
}
|
|
|
|
pre {
|
|
background: #0b1220;
|
|
color: #e6eef8;
|
|
padding: 16px;
|
|
border-radius: 8px;
|
|
overflow: auto
|
|
}
|
|
|
|
h3 {
|
|
margin-top: 28px;
|
|
margin-bottom: 12px;
|
|
font-size: 18px;
|
|
color: #0f172a
|
|
}
|
|
|
|
p {
|
|
margin-bottom: 18px;
|
|
color: #374151
|
|
}
|
|
|
|
small {
|
|
color: #6b7280
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div class="container">
|
|
<h1>Preparedness survey — quick form</h1>
|
|
<p>Fill the fields you have, then use <strong>Predict</strong> to send the form as JSON to the server's
|
|
<code>/predict</code> endpoint and see the model response below.
|
|
</p>
|
|
<form id="survey">
|
|
<!-- dynamic form will be rendered here from metadata -->
|
|
<div id="dynamic-grid" class="grid"></div>
|
|
|
|
<!-- All static questions removed. The form is fully metadata-driven and rendered into #dynamic-grid -->
|
|
|
|
<div class="actions">
|
|
<button type="button" id="predict">Predict</button>
|
|
<button type="button" id="selftest">Self-test</button>
|
|
</div>
|
|
|
|
<h3>JSON preview</h3>
|
|
<pre id="output">{ }</pre>
|
|
</form>
|
|
<script>
|
|
// Note: selects were converted to static markup; label/likert styling is handled in the CSS above.
|
|
const form = document.getElementById('survey');
|
|
const output = document.getElementById('output');
|
|
|
|
function collectForm() {
|
|
const data = {};
|
|
// include all inputs/selects
|
|
const elements = Array.from(form.elements).filter(e => e.name);
|
|
// handle radios by grouping
|
|
const handled = new Set();
|
|
elements.forEach(el => {
|
|
const name = el.name;
|
|
if (handled.has(name)) return;
|
|
if (el.type === 'radio') {
|
|
const radios = form.querySelectorAll(`input[name="${name}"]`);
|
|
const checked = Array.from(radios).find(r => r.checked);
|
|
data[name] = checked ? checked.value : null;
|
|
handled.add(name);
|
|
return;
|
|
}
|
|
if (el.type === 'checkbox') {
|
|
// if this checkbox was created as a toggle with data-yes/data-no, return the string
|
|
const yes = el.dataset && el.dataset.yes;
|
|
const no = el.dataset && el.dataset.no;
|
|
if (yes !== undefined && no !== undefined) {
|
|
data[name] = el.checked ? yes : no;
|
|
} else {
|
|
data[name] = el.checked ? 1 : 0;
|
|
}
|
|
handled.add(name);
|
|
return;
|
|
}
|
|
if (el.type === 'number' || el.type === 'range') {
|
|
const v = el.value;
|
|
data[name] = v === '' ? null : Number(v);
|
|
handled.add(name);
|
|
return;
|
|
}
|
|
// select, text, textarea, etc.
|
|
data[name] = el.value === '' ? null : el.value;
|
|
handled.add(name);
|
|
});
|
|
return data;
|
|
}
|
|
|
|
// Map compact option values to human-friendly labels used in the canonical example
|
|
const VALUE_LABELS = {
|
|
// general likert / frequency
|
|
'very_much': 'A great deal',
|
|
'somewhat': 'Somewhat',
|
|
'not_much': 'Not much',
|
|
'not_at_all': 'Not at all',
|
|
|
|
'strongly_agree': 'Strongly agree',
|
|
'agree': 'Agree',
|
|
'neutral': 'Neutral',
|
|
'disagree': 'Disagree',
|
|
'strongly_disagree': 'Strongly disagree',
|
|
|
|
'very_trust': 'Very trust',
|
|
'somewhat_trust': 'Somewhat trust',
|
|
'somewhat_distrust': 'Somewhat distrust',
|
|
'very_distrust': 'Very distrust',
|
|
|
|
'daily': 'Daily',
|
|
'weekly': 'Weekly',
|
|
'monthly': 'Monthly',
|
|
'rarely': 'Rarely',
|
|
'never': 'Never',
|
|
|
|
'female': 'Female',
|
|
'male': 'Male',
|
|
'other': 'Other',
|
|
'prefer_not': 'Prefer not to say',
|
|
|
|
'getting_better': 'Getting better',
|
|
'staying_same': 'Staying the same',
|
|
'getting_worse': 'Getting worse',
|
|
|
|
'often': 'Often',
|
|
'sometimes': 'Sometimes',
|
|
|
|
'improved': 'Improved',
|
|
'same': 'Same',
|
|
'worse': 'Worse',
|
|
'better': 'Better',
|
|
|
|
'rural': 'Rural',
|
|
'small_town': 'Small town',
|
|
'suburb': 'Suburb',
|
|
'city': 'City',
|
|
|
|
// toggles / yes-no
|
|
'yes': 'Yes',
|
|
'no': 'No'
|
|
};
|
|
|
|
// SERVER_VALUE_LABELS will hold the variable-level mappings returned from the server
|
|
// structure: { varName: { codeStr: label, ... }, ... }
|
|
let SERVER_VALUE_LABELS = {};
|
|
// FIELD_LABELS maps our stable field name (input.name) -> human-friendly label
|
|
// FIELD_INTERNALS maps stable field name -> server-provided internal_label (snake_case id)
|
|
// populated when we render the variables list so we can use internal_label when sending
|
|
// the combined text (and still show human-friendly labels in the UI).
|
|
let FIELD_LABELS = {};
|
|
let FIELD_INTERNALS = {};
|
|
// FIELD_VARNAMES maps stable field name -> canonical variable id (as returned in variables[].id)
|
|
// we need this so we can look up SERVER_VALUE_LABELS by the canonical id when resolving labels
|
|
let FIELD_VARNAMES = {};
|
|
// VAR_VALUES stores the values map returned in /variables for each canonical var id
|
|
// structure: { varId: { code: label, ... }, ... }
|
|
let VAR_VALUES = {};
|
|
|
|
// Load server-provided variable metadata and render the entire form from it.
|
|
(async function loadVariablesAndRender() {
|
|
try {
|
|
// Load variable metadata (ordered list)
|
|
const varsResp = await fetch('/variables');
|
|
if (!varsResp.ok) throw new Error('Could not load /variables');
|
|
const variables = await varsResp.json();
|
|
|
|
// load server-side value labels for quick lookup too
|
|
const vlResp = await fetch('/value_labels');
|
|
if (vlResp.ok) {
|
|
SERVER_VALUE_LABELS = await vlResp.json();
|
|
}
|
|
|
|
const grid = document.getElementById('dynamic-grid');
|
|
grid.innerHTML = '';
|
|
console.log('[survey] Loaded', variables.length, 'variables from /variables');
|
|
|
|
// heuristic: certain variable name prefixes indicate a range/numeric field
|
|
function looksLikeRange(varId) {
|
|
return varId.startsWith('how_many_days') || varId.startsWith('age_') || varId.startsWith('age') || varId.startsWith('age_recoded');
|
|
}
|
|
|
|
variables.forEach(v => {
|
|
const row = document.createElement('div');
|
|
row.className = 'form-row';
|
|
|
|
const lbl = document.createElement('label');
|
|
lbl.textContent = v.label || v.id;
|
|
row.appendChild(lbl);
|
|
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'input-wrap';
|
|
// stable field name for submission: use snake_case of id
|
|
const fieldName = String(v.id).toLowerCase().replace(/[^a-z0-9]+/g, '_');
|
|
wrap.setAttribute('data-field', fieldName);
|
|
wrap.setAttribute('data-var', v.id);
|
|
// remember the human label and internal_label for display and submission
|
|
FIELD_LABELS[fieldName] = v.label || v.id;
|
|
FIELD_INTERNALS[fieldName] = v.internal_label || fieldName;
|
|
FIELD_VARNAMES[fieldName] = v.id;
|
|
VAR_VALUES[v.id] = v.values || {};
|
|
console.log('[survey] FIELD_LABELS set:', fieldName, '=>', FIELD_LABELS[fieldName]);
|
|
console.log('[survey] FIELD_INTERNALS set:', fieldName, '=>', FIELD_INTERNALS[fieldName]);
|
|
|
|
// if we have value labels for this var, render radios or toggles
|
|
const vals = v.values || {};
|
|
const codes = Object.keys(vals || {});
|
|
if (codes.length === 0) {
|
|
// no canonical values: choose text or range based on heuristics
|
|
if (looksLikeRange(v.id)) {
|
|
const inp = document.createElement('input');
|
|
inp.type = 'range';
|
|
inp.name = fieldName;
|
|
inp.id = fieldName;
|
|
inp.min = 0;
|
|
inp.max = 30;
|
|
inp.step = 1;
|
|
wrap.appendChild(inp);
|
|
const span = document.createElement('span');
|
|
span.className = 'range-value';
|
|
span.setAttribute('data-for', fieldName);
|
|
span.textContent = '0';
|
|
wrap.appendChild(span);
|
|
} else {
|
|
const inp = document.createElement('input');
|
|
inp.type = 'text';
|
|
inp.name = fieldName;
|
|
inp.id = fieldName;
|
|
wrap.appendChild(inp);
|
|
}
|
|
} else {
|
|
// render canonical codes -> labels; detect binary numeric yes/no
|
|
const numericCodes = codes.map(c => Number(String(c))).filter(n => !Number.isNaN(n));
|
|
const isBinary = (codes.length === 2) && (numericCodes.includes(0) || numericCodes.includes(1) || numericCodes.includes(2));
|
|
if (isBinary) {
|
|
const yesCode = codes.find(c => Number(c) === 1) || codes.find(c => Number(c) === 2) || codes[0];
|
|
const noCode = codes.find(c => c !== yesCode) || null;
|
|
const wrapper = document.createElement('div');
|
|
const label = document.createElement('label');
|
|
label.className = 'toggle-switch';
|
|
const input = document.createElement('input');
|
|
input.type = 'checkbox';
|
|
input.name = fieldName;
|
|
input.setAttribute('data-yes', yesCode.replace(/\.0$/, ''));
|
|
input.setAttribute('data-no', noCode ? noCode.replace(/\.0$/, '') : '0');
|
|
const slider = document.createElement('span');
|
|
slider.className = 'slider';
|
|
label.appendChild(input);
|
|
label.appendChild(slider);
|
|
wrapper.appendChild(label);
|
|
const small = document.createElement('small');
|
|
small.style.marginLeft = '10px';
|
|
small.textContent = vals[yesCode] || 'Yes';
|
|
wrapper.appendChild(small);
|
|
wrap.appendChild(wrapper);
|
|
} else {
|
|
const likert = document.createElement('div');
|
|
likert.className = 'likert';
|
|
codes.slice().sort((a,b) => {
|
|
const na = Number(a), nb = Number(b);
|
|
if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
|
|
return String(a).localeCompare(String(b));
|
|
}).forEach(code => {
|
|
const label = document.createElement('label');
|
|
const input = document.createElement('input');
|
|
input.type = 'radio';
|
|
input.name = fieldName;
|
|
input.value = String(code).replace(/\.0$/, '');
|
|
label.appendChild(input);
|
|
label.appendChild(document.createTextNode(' ' + (vals[code] || code)));
|
|
likert.appendChild(label);
|
|
});
|
|
wrap.appendChild(likert);
|
|
}
|
|
}
|
|
|
|
row.appendChild(wrap);
|
|
grid.appendChild(row);
|
|
});
|
|
|
|
// init sliders to show values
|
|
initSliders();
|
|
|
|
} catch (e) {
|
|
console.warn('Failed to load variables:', e && e.message ? e.message : e);
|
|
}
|
|
})();
|
|
|
|
// Render inputs for elements with data-var attribute using SERVER_VALUE_LABELS.
|
|
// For each container with data-var="<varname>", we create controls based on the mapping:
|
|
// - If the mapping looks binary (codes like 0/1) we render a toggle checkbox (data-yes/data-no)
|
|
// - Otherwise we render radio inputs for each code->label pair (values are code without .0)
|
|
function renderVariableControls() {
|
|
console.log('[survey] renderVariableControls start, SERVER_VALUE_LABELS keys:', Object.keys(SERVER_VALUE_LABELS).length);
|
|
const containers = Array.from(document.querySelectorAll('[data-var]'));
|
|
containers.forEach(container => {
|
|
const varName = container.getAttribute('data-var');
|
|
if (!varName) return;
|
|
const map = SERVER_VALUE_LABELS[varName];
|
|
if (!map || typeof map !== 'object') return;
|
|
// clear existing content
|
|
container.innerHTML = '';
|
|
const codes = Object.keys(map);
|
|
// detect binary mapping (common pattern: 0.0/1.0 or 1.0/2.0 for yes/no)
|
|
const numericCodes = codes.map(c => Number(String(c)) ).filter(n => !Number.isNaN(n));
|
|
const isBinary = (codes.length === 2) && (numericCodes.includes(0) || numericCodes.includes(1) || numericCodes.includes(2));
|
|
const fieldName = container.getAttribute('data-field') || varName;
|
|
|
|
if (isBinary) {
|
|
// determine the 'yes' code (prefer 1, then 2)
|
|
const yesCode = codes.find(c => Number(c) === 1) || codes.find(c => Number(c) === 2) || codes[0];
|
|
const noCode = codes.find(c => c !== yesCode) || null;
|
|
const yesLabel = map[yesCode] || 'Yes';
|
|
const noLabel = noCode ? map[noCode] : 'No';
|
|
// checkbox: when checked -> yes (1), unchecked -> no (0)
|
|
const wrapper = document.createElement('div');
|
|
const label = document.createElement('label');
|
|
label.className = 'toggle-switch';
|
|
const input = document.createElement('input');
|
|
input.type = 'checkbox';
|
|
input.name = fieldName;
|
|
// encode yes/no as data attributes so collectForm interprets correctly
|
|
input.setAttribute('data-yes', yesCode.replace(/\.0$/, ''));
|
|
input.setAttribute('data-no', noCode ? noCode.replace(/\.0$/, '') : '0');
|
|
const slider = document.createElement('span');
|
|
slider.className = 'slider';
|
|
label.appendChild(input);
|
|
label.appendChild(slider);
|
|
wrapper.appendChild(label);
|
|
const small = document.createElement('small');
|
|
small.style.marginLeft = '10px';
|
|
small.textContent = yesLabel;
|
|
wrapper.appendChild(small);
|
|
container.appendChild(wrapper);
|
|
} else {
|
|
// render radios; sort codes numerically when possible for stable ordering
|
|
const sorted = codes.slice().sort((a, b) => {
|
|
const na = Number(a), nb = Number(b);
|
|
if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
|
|
return String(a).localeCompare(String(b));
|
|
});
|
|
sorted.forEach(code => {
|
|
const labelText = map[code];
|
|
const valueToken = String(code).replace(/\.0$/, '');
|
|
const label = document.createElement('label');
|
|
const input = document.createElement('input');
|
|
input.type = 'radio';
|
|
input.name = fieldName;
|
|
input.value = valueToken;
|
|
label.appendChild(input);
|
|
label.appendChild(document.createTextNode(' ' + labelText));
|
|
container.appendChild(label);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Helper: return the human-friendly label for a given field and token.
|
|
// Lookup order:
|
|
// 1) If SERVER_VALUE_LABELS has an entry for fieldName and a matching code -> return label.
|
|
// We attempt exact match on token, token+'.0', and token with trailing '.0' removed.
|
|
// 2) If VALUE_LABELS has an entry for token -> return that.
|
|
// 3) Else return null to indicate no mapping.
|
|
function labelFor(fieldName, token) {
|
|
if (!token || token === 'nan') return null;
|
|
// server-side per-variable lookup
|
|
// determine canonical var id for this field so we can resolve per-variable value labels
|
|
const varKey = FIELD_VARNAMES[fieldName] || fieldName;
|
|
// Prefer VAR_VALUES (values shipped with /variables) as it contains the recoded labels
|
|
if (VAR_VALUES && VAR_VALUES[varKey]) {
|
|
const map = VAR_VALUES[varKey];
|
|
// 1) If the token directly matches a code key, return the server label
|
|
if (map.hasOwnProperty(token)) return map[token];
|
|
// try token + '.0' and stripped
|
|
if (map.hasOwnProperty(token + '.0')) return map[token + '.0'];
|
|
const strippedToken = token.replace(/\.0$/, '');
|
|
if (map.hasOwnProperty(strippedToken)) return map[strippedToken];
|
|
// 2) If the token maps locally to a human label, see if the server map contains that label
|
|
// (this lets us map semantic tokens like 'very_much' -> 'A great deal' if the server
|
|
// contains that exact human label for the field)
|
|
if (VALUE_LABELS.hasOwnProperty(token)) {
|
|
const human = VALUE_LABELS[token];
|
|
// try exact value match (case-sensitive); also try trimmed/case-insensitive
|
|
for (const k of Object.keys(map)) {
|
|
const serverLabel = map[k];
|
|
if (!serverLabel) continue;
|
|
if (serverLabel === human) return serverLabel;
|
|
if (serverLabel.trim().toLowerCase() === human.trim().toLowerCase()) return serverLabel;
|
|
}
|
|
}
|
|
// 3) additional numeric equivalence handled above
|
|
// 4) numeric equivalence match
|
|
try {
|
|
const numToken = Number(token);
|
|
if (!Number.isNaN(numToken)) {
|
|
for (const k of Object.keys(map)) {
|
|
if (!isNaN(Number(k)) && Number(k) === numToken) return map[k];
|
|
}
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
// fallback to server-provided per-variable mapping if present
|
|
if (SERVER_VALUE_LABELS && SERVER_VALUE_LABELS[varKey]) {
|
|
const map = SERVER_VALUE_LABELS[varKey];
|
|
if (map.hasOwnProperty(token)) return map[token];
|
|
if (map.hasOwnProperty(token + '.0')) return map[token + '.0'];
|
|
const stripped2 = token.replace(/\.0$/, '');
|
|
if (map.hasOwnProperty(stripped2)) return map[stripped2];
|
|
try {
|
|
const numToken = Number(token);
|
|
if (!Number.isNaN(numToken)) {
|
|
for (const k of Object.keys(map)) {
|
|
if (!isNaN(Number(k)) && Number(k) === numToken) return map[k];
|
|
}
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
// fallback to global VALUE_LABELS exact match
|
|
if (VALUE_LABELS.hasOwnProperty(token)) {
|
|
console.log('[survey] labelFor fallback global VALUE_LABELS for', token, '->', VALUE_LABELS[token]);
|
|
return VALUE_LABELS[token];
|
|
}
|
|
console.log('[survey] labelFor no mapping for', fieldName, token);
|
|
return null;
|
|
}
|
|
|
|
document.getElementById('predict').addEventListener('click', async () => {
|
|
const d = collectForm();
|
|
// Build a combined string of key:value for all form fields in a stable order
|
|
// Missing values become 'nan' to mirror downstream expectations
|
|
const parts = [];
|
|
Object.keys(d).forEach(k => {
|
|
let v = d[k];
|
|
if (v === null || v === undefined || v === '') {
|
|
v = 'nan';
|
|
} else if (typeof v === 'object') {
|
|
v = JSON.stringify(v);
|
|
} else {
|
|
v = String(v);
|
|
}
|
|
|
|
// Attempt to map token to friendly label using per-field labels first,
|
|
// then global fallback.
|
|
if (typeof v === 'string') {
|
|
const fieldLabelVal = labelFor(k, v);
|
|
if (fieldLabelVal) {
|
|
v = fieldLabelVal;
|
|
}
|
|
}
|
|
|
|
// Use server-provided internal_label (if available) as the key in the combined text
|
|
// Fall back to the human-friendly FIELD_LABELS or the field name
|
|
const internalKey = FIELD_INTERNALS[k] || FIELD_LABELS[k] || k;
|
|
parts.push(`${internalKey}: ${v}`);
|
|
});
|
|
const combined = parts.join(' | ');
|
|
|
|
output.textContent = 'Sending...';
|
|
console.log('[survey] Sending combined payload to /predict');
|
|
try {
|
|
// Build an internal-keyed mapping to send alongside the combined text
|
|
const request_parsed_internal = {};
|
|
Object.keys(d).forEach(k => {
|
|
const internalKey = FIELD_INTERNALS[k] || FIELD_LABELS[k] || k;
|
|
request_parsed_internal[internalKey] = d[k] === null || d[k] === undefined || d[k] === '' ? 'nan' : d[k];
|
|
});
|
|
|
|
const res = await fetch('/predict', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text: combined })
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
output.textContent = `Error ${res.status}: ${text}`;
|
|
return;
|
|
}
|
|
const json = await res.json();
|
|
output.textContent = JSON.stringify(json, null, 2);
|
|
} catch (err) {
|
|
output.textContent = 'Network error: ' + (err && err.message ? err.message : String(err));
|
|
}
|
|
});
|
|
|
|
// Self-test: programmatically choose first option in each likert group, enable toggles and fill a couple fields,
|
|
// then trigger the Predict flow so testers can quickly exercise the endpoint.
|
|
function fillSelfTest() {
|
|
// pick first radio of each group
|
|
console.log('[survey] fillSelfTest starting');
|
|
const radios = Array.from(document.querySelectorAll('input[type=radio]'));
|
|
const grouped = {};
|
|
radios.forEach(r => { if (!grouped[r.name]) grouped[r.name] = []; grouped[r.name].push(r); });
|
|
Object.values(grouped).forEach(g => { if (g.length) g[0].checked = true; });
|
|
|
|
// enable all toggle checkboxes (rendered as .toggle-switch input)
|
|
document.querySelectorAll('.toggle-switch input[type=checkbox]').forEach(ch => ch.checked = true);
|
|
|
|
// fill first text inputs
|
|
const firstText = document.querySelector('input[type=text]'); if (firstText) firstText.value = 'Sample';
|
|
|
|
// set ranges to mid values
|
|
document.querySelectorAll('input[type=range]').forEach(r => {
|
|
const min = Number(r.min || 0);
|
|
const max = Number(r.max || 10);
|
|
r.value = Math.floor((min + max) / 2);
|
|
r.dispatchEvent(new Event('input'));
|
|
});
|
|
|
|
console.log('[survey] fillSelfTest done populating fields');
|
|
// trigger predict
|
|
const btn = document.getElementById('predict');
|
|
if (btn) btn.click();
|
|
}
|
|
|
|
document.getElementById('selftest').addEventListener('click', fillSelfTest);
|
|
|
|
// Keep range-value spans in sync with range inputs
|
|
function initSliders() {
|
|
const ranges = Array.from(document.querySelectorAll('input[type=range]'));
|
|
ranges.forEach(r => {
|
|
const span = document.querySelector(`.range-value[data-for="${r.id}"]`);
|
|
const update = () => {
|
|
if (span) span.textContent = r.value;
|
|
// set ARIA value
|
|
r.setAttribute('aria-valuenow', r.value);
|
|
};
|
|
// initialize
|
|
update();
|
|
r.addEventListener('input', update);
|
|
});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', initSliders);
|
|
</script>
|
|
|
|
<p><small>If you need CSV export or different field typing, tell me and I'll add it.</small></p>
|
|
</div>
|
|
</body>
|
|
|
|
</html> |