// Inline CSS for the page header (title + logo row), same pattern as nutrient.qmd
html`
<style>
.header-flex {
display: flex;
align-items: center;
margin-bottom: 0em;
}
.header-title {
font-size: 1.5em;
font-weight: bold;
}
</style>
`
Analysdata
// Two free-text fields (Plats/Operatör) shown at the top of the page,
// built the same way as the "analysdata" table below: a compound
// element that dispatches its own "input" event so it works as a
// `viewof`, with `.value` exposing both fields as one object.
viewof meta = {
const platsInput = Object.assign(html`<input type="text" class="meta-input" placeholder="Ange plats">`, {});
const operatorInput = Object.assign(html`<input type="text" class="meta-input" placeholder="Ange operatör">`, {});
const container = html`<div class="meta-row">
<label class="meta-field">Plats
${platsInput}
</label>
<label class="meta-field">Operatör
${operatorInput}
</label>
</div>`;
function currentValue() {
return {plats: platsInput.value, operator: operatorInput.value};
}
container.value = currentValue();
for (const input of [platsInput, operatorInput]) {
input.addEventListener("input", () => {
container.value = currentValue();
container.dispatchEvent(new Event("input", {bubbles: true}));
});
}
return container;
}// Collapsible instruction panel shown at the top of the page.
// <summary> is styled as a solid button (see .instruktion-toggle in
// styles.css) instead of a plain text link, while <details> still
// handles the expand/collapse natively — no extra JS needed.
html`
<details>
<summary class="instruktion-toggle">Instruktion</summary>
<div class="instruktion-body">
Fyll i uppmätta analysvärden i tabellen nedan. Varje rad visar det tillåtna intervallet (min–max) för parametern. Om ett angivet värde ligger utanför intervallet visas en kommentar, hämtad från en textfil i mappen "comments", i kolumnen längst till höger.
</div>
</details>
<hr style="border-top:5px #03865a;">
`// ---------------------------------------------------------------------
// Table definition.
// Add a new analysis parameter by adding another object to this array.
// `commentFile` must be a literal FileAttachment("comments/<name>.txt")
// call (not a dynamically built path) so that Quarto can detect the file
// at render time and copy it into the published site.
// ---------------------------------------------------------------------
params = [
{
key: "substans", // internal id, used to read back the value elsewhere
item: "Substans", // label shown in the "Item" column
unit: "g/L", // label shown in the "Enhet" column
value: 5, // default value shown in the input box
min: 2, // lower bound of the accepted range
max: 9, // upper bound of the accepted range
step: 0.1, // increment used by the input's up/down arrows
commentFile: FileAttachment("comments/substans.txt") // text shown when value is out of range
},
{
key: "totalkvave",
item: "Totalkväve (N)",
unit: "g/L",
value: 0.49,
min: 0.28,
max: 0.70,
step: 0.01,
commentFile: FileAttachment("comments/totalkvave.txt")
},
{
key: "nhkvave",
item: "NH3/4-kväve (NH-N)",
unit: "g/L",
value: 0.40,
min: 0.25,
max: 0.54,
step: 0.01,
commentFile: FileAttachment("comments/nhkvave.txt")
},
{
key: "fosfor",
item: "Fosfor (P)",
unit: "g/L",
value: 0.054,
min: 0.018,
max: 0.090,
step: 0.001,
commentFile: FileAttachment("comments/fosfor.txt")
},
{
key: "kalium",
item: "Kalium (K)",
unit: "g/L",
value: 0.13,
min: 0.088,
max: 0.17,
step: 0.001,
commentFile: FileAttachment("comments/kalium.txt")
},
{
key: "svavel",
item: "Svavel (S)",
unit: "g/L",
value: 2,
min: 1, // not shown in the source table; confirmed with the user
max: 3,
step: 0.1,
commentFile: FileAttachment("comments/svavel.txt")
},
{
key: "kalcium",
item: "Kalcium (Ca)",
unit: "g/L",
value: 0.125,
min: 0.01,
max: 0.24,
step: 0.001,
commentFile: FileAttachment("comments/kalcium.txt")
},
{
key: "magnesium",
item: "Magnesium (Mg)",
unit: "g/L",
value: 0.066,
min: 0.002,
max: 0.13,
step: 0.001,
commentFile: FileAttachment("comments/magnesium.txt")
}
]// Read the text of every comment file up front (in parallel) and store
// it in a Map keyed by param.key, so the table cell below can just look
// it up synchronously instead of dealing with promises per keystroke.
comments = new Map(
await Promise.all(
params.map(async p => [p.key, await p.commentFile.text()])
)
)// ---------------------------------------------------------------------
// Builds the reactive input table.
// Each row gets its own <input type="number"> element. Editing any input
// dispatches an "input" event on the returned <table>, which is how a
// custom (non-Inputs.*) element becomes a valid OJS `viewof` — Quarto/
// Observable listens for that event and re-reads `table.value`.
// ---------------------------------------------------------------------
viewof analysdata = {
// `params[i].min/max/step/value` are always expressed in g/L — that
// canonical unit never changes. The unit <select> only changes how
// those numbers are *displayed*; this factor converts between the
// canonical g/L number and the currently displayed unit.
function factor(unit) {
return unit === "mg/L" ? 1000 : 1;
}
// Rounds away floating-point noise (e.g. 0.29 * 1000 = 290.00000000000006).
function round(x) {
return Math.round(x * 1e6) / 1e6;
}
// One row per parameter: a number input for the value, a unit <select>
// (g/L / mg/L), and two <span>s for the Min/Max cells that need to be
// rewritten whenever the unit changes.
// `step` must be a real number (not "any") for the browser's built-in
// up/down spinner arrows to work — with step="any" the arrows render
// but stepUp()/stepDown() are no-ops, so clicking them does nothing.
const rows = params.map(p => ({
p,
currentUnit: p.unit,
input: Object.assign(html`<input type="number" class="analys-input">`, {
value: p.value
}),
unitSelect: Object.assign(html`<select class="analys-unit">
<option value="g/L">g/L</option>
<option value="mg/L">mg/L</option>
</select>`, {
value: p.unit
}),
minCell: html`<span></span>`,
maxCell: html`<span></span>`,
batchInput: Object.assign(html`<input type="text" class="analys-input analys-batch-input" maxlength="15" size="15">`, {
value: ""
})
}));
// Applies the row's currently selected unit to the input's min/max/step
// attributes and to the displayed Min/Max cells (all derived from the
// canonical g/L numbers in `p`).
function refreshRange(row) {
const f = factor(row.unitSelect.value);
row.input.min = round(row.p.min * f);
row.input.max = round(row.p.max * f);
row.input.step = round(row.p.step * f);
row.minCell.textContent = round(row.p.min * f);
row.maxCell.textContent = round(row.p.max * f);
}
for (const row of rows) refreshRange(row);
// The table markup. <colgroup> pins each column to a fixed width so
// that the layout does not shift when the comment text appears or
// disappears — only the row height grows if the comment text wraps.
const table = html`<table class="analys-table">
<colgroup>
<col class="col-item">
<col class="col-batch">
<col class="col-unit">
<col class="col-value">
<col class="col-minmax">
<col class="col-minmax">
<col class="col-note">
</colgroup>
<thead>
<tr>
<th>Item</th>
<th>Batch ID</th>
<th>Enhet</th>
<th>Värde</th>
<th>Min</th>
<th>Max</th>
<th>Kommentar</th>
</tr>
</thead>
<tbody>
${rows.map(({p, input, unitSelect, minCell, maxCell, batchInput}) => html`<tr>
<td>${p.item}</td>
<td>${batchInput}</td>
<td>${unitSelect}</td>
<td>${input}</td>
<td>${minCell}</td>
<td>${maxCell}</td>
<td class="analys-note" data-key="${p.key}"></td>
</tr>`)}
</tbody>
</table>`;
// Reads the current numeric value and selected unit out of every row,
// e.g. {substans: {value: 5, unit: "g/L"}} — this becomes `analysdata`
// wherever it is referenced.
function currentValue() {
return Object.fromEntries(
rows.map(({p, input, unitSelect, batchInput}) => [p.key, {value: input.valueAsNumber, unit: unitSelect.value, batchId: batchInput.value}])
);
}
// Fills in (or clears) the comment cell for every row, depending on
// whether the entered value — converted back to the canonical g/L
// scale — falls inside [min, max].
function updateNotes() {
for (const {p, input, unitSelect} of rows) {
const cell = table.querySelector(`.analys-note[data-key="${p.key}"]`);
const v = input.valueAsNumber / factor(unitSelect.value);
if (Number.isNaN(v)) {
cell.textContent = "Ange ett värde";
cell.classList.add("analys-warning");
} else if (v < p.min || v > p.max) {
cell.textContent = comments.get(p.key);
cell.classList.add("analys-warning");
} else {
cell.textContent = "";
cell.classList.remove("analys-warning");
}
}
}
// Initial render, then wire up each input/select so editing either one
// updates the comment column and the value exposed to the notebook.
table.value = currentValue();
updateNotes();
for (const row of rows) {
row.input.addEventListener("input", () => {
table.value = currentValue();
updateNotes();
table.dispatchEvent(new Event("input", {bubbles: true}));
});
// Switching the unit converts the number already typed in (via the
// canonical g/L value) so it keeps representing the same quantity,
// instead of resetting it or silently misinterpreting it.
row.unitSelect.addEventListener("change", () => {
const canonicalValue = row.input.valueAsNumber / factor(row.currentUnit);
refreshRange(row);
row.input.valueAsNumber = round(canonicalValue * factor(row.unitSelect.value));
row.currentUnit = row.unitSelect.value;
table.value = currentValue();
updateNotes();
table.dispatchEvent(new Event("input", {bubbles: true}));
});
// Batch ID is free text and does not affect the min/max comparison,
// so it only needs to update the exposed value, not the comments.
row.batchInput.addEventListener("input", () => {
table.value = currentValue();
table.dispatchEvent(new Event("input", {bubbles: true}));
});
}
return table;
}// Hidden marker only shown while printing/exporting (see .print-timestamp
// in styles.css) — filled in by pdfButton below.
html`<p class="print-timestamp"></p>`// ---------------------------------------------------------------------
// "Ladda ner PDF" button — same pattern as in nutrient.qmd/ammonia.qmd:
// it screenshots the whole page with html2canvas and drops that image
// into a jsPDF document sized to fit the page.
// ---------------------------------------------------------------------
pdfButton = {
const btn = html`<button id="pdf-download-btn" style="background-color: #03865a; color: white; border: none; padding: 8px 20px; border-radius: 4px;">Ladda ner PDF</button>`;
const originalLabel = btn.innerText;
btn.onclick = async () => {
btn.disabled = true;
btn.innerText = "Skapar PDF...";
// Stamp the current date/time into the page before capturing it.
const stamp = document.querySelector(".print-timestamp");
stamp.style.display = "block";
stamp.innerText = "Utskrivet: " + new Date().toLocaleString("sv-SE");
// Force the Instruktion panel open so its text is included in the
// PDF, then restore whatever state it was in before.
const instruktion = document.querySelector("details");
const instruktionWasOpen = instruktion ? instruktion.open : null;
if (instruktion) instruktion.open = true;
window.scrollTo(0, 0);
const target = document.body;
const canvas = await html2canvas(target, {
scale: 2,
backgroundColor: "#ffffff",
ignoreElements: (el) => el.id === "pdf-download-btn" || el.id === "quarto-header"
});
stamp.style.display = "none";
if (instruktion) instruktion.open = instruktionWasOpen;
const { jsPDF } = window.jspdf;
const orientation = canvas.width > canvas.height ? "landscape" : "portrait";
const pdf = new jsPDF({ orientation, unit: "mm", format: "a4" });
const margin = 10;
const pageWidth = pdf.internal.pageSize.getWidth() - margin * 2;
const pageHeight = pdf.internal.pageSize.getHeight() - margin * 2;
let imgWidth = pageWidth;
let imgHeight = (canvas.height * imgWidth) / canvas.width;
if (imgHeight > pageHeight) {
imgHeight = pageHeight;
imgWidth = (canvas.width * imgHeight) / canvas.height;
}
pdf.addImage(canvas.toDataURL("image/png"), "PNG", margin, margin, imgWidth, imgHeight);
pdf.save("analysdata-resultat.pdf");
btn.disabled = false;
btn.innerText = originalLabel;
};
return btn;
}