feat: figures are thumbnails that open into a viewer, with source and overlay
A 2,000px radiograph written into an article rendered at whatever width it happened to be — a wall of greyscale in the middle of a sentence, four megabytes to draw it, and no way to look at it properly. Every image in prose is now a 256px thumbnail with the author's label under it, and a click gives it the screen. The viewer puts what is known about the figure beside it: its title, what it shows, and where it came from. `media_assets` gains `source` and `source_url` for that — a citation belongs to the file, because the same figure used in three articles is cited the same way in all three, and a licence that turns out to be wrong is one row to fix rather than three paragraphs to find. Asked for when the figure is opened, not when the page is drawn. And `overlay`: the regions an educator has marked, as vector shapes in normalised coordinates on the unit square, so one drawing is correct in a thumbnail, in the viewer and on a projector. Off until the learner turns it on — marks shown before they have looked answer the question for them. Vectors rather than a second burnt-in picture, for four reasons written down in docs/image-overlays.md. The tool that draws them is next; this is the storage, the contract and the reader's half. On a narrow screen the description stacks above the image rather than beside it, where it can be read before scrolling to the picture. Also here: `classify_question_difficulty`, which labelled all 2,924 questions in batches of twenty-five against a written rubric — 622 easy, 1,634 medium, 668 hard, no failed batches. The column had been NULL on every row since it existed. Migration l2c3d4e5f6a7. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
0d41179b30
commit
809ac2fca6
11 changed files with 618 additions and 9 deletions
40
backend/alembic/versions/l2c3d4e5f6a7_media_source.py
Normal file
40
backend/alembic/versions/l2c3d4e5f6a7_media_source.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""Where an image came from, and what has been marked on it
|
||||
|
||||
`source` is the citation as it should be read — figure, authors, publication,
|
||||
licence — and `source_url` is where to check it. `overlay` holds the regions an
|
||||
educator has marked on the image, as vector shapes in normalised coordinates so
|
||||
they land in the right place at any size. Both on the asset rather than
|
||||
typed into each caption: the same figure used in three articles is cited the
|
||||
same way in all three, and a licence that turns out to be wrong is one row to
|
||||
fix rather than three paragraphs to find.
|
||||
|
||||
Revision ID: l2c3d4e5f6a7
|
||||
Revises: k1b2c3d4e5f6
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "l2c3d4e5f6a7"
|
||||
down_revision = "k1b2c3d4e5f6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
COLUMNS = {
|
||||
"source": sa.Column("source", sa.Text(), nullable=True),
|
||||
"source_url": sa.Column("source_url", sa.String(length=600), nullable=True),
|
||||
# Vector shapes in normalised coordinates, not a second raster: an overlay
|
||||
# has to be switchable, correctable and correct at any size.
|
||||
"overlay": sa.Column("overlay", sa.JSON(), nullable=True),
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
existing = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("media_assets")}
|
||||
for name, column in COLUMNS.items():
|
||||
if name not in existing:
|
||||
op.add_column("media_assets", column)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for name in COLUMNS:
|
||||
op.drop_column("media_assets", name)
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy import (Column, DateTime, ForeignKey, Integer, JSON, String, Text,
|
||||
UniqueConstraint)
|
||||
|
||||
from app.database import Base
|
||||
from app.models.embeddable import Embeddable
|
||||
|
|
@ -21,6 +22,21 @@ class MediaAsset(Base, Embeddable):
|
|||
title = Column(String(300), nullable=True)
|
||||
caption = Column(Text, nullable=True)
|
||||
alt_text = Column(Text, nullable=True)
|
||||
# Where the image came from, and where to check that. Kept on the asset
|
||||
# rather than typed into a caption because a citation belongs to the file:
|
||||
# the same figure used in three articles is cited the same way in all
|
||||
# three, and a licence that turns out to be wrong is one row to fix.
|
||||
source = Column(Text, nullable=True)
|
||||
source_url = Column(String(600), nullable=True)
|
||||
# Regions an educator has marked on the image, as vector shapes in
|
||||
# normalised coordinates — see `docs/image-overlays.md`. Vectors rather
|
||||
# than a second burnt-in picture: a raster overlay cannot be turned off,
|
||||
# cannot be corrected without redrawing it, doubles the storage, and looks
|
||||
# wrong at any size but the one it was drawn at.
|
||||
#
|
||||
# {"shapes": [{"kind": "path", "points": [[0.31, 0.52], …],
|
||||
# "color": "#5eead4", "width": 0.006, "label": "lead lines"}]}
|
||||
overlay = Column(JSON, nullable=True)
|
||||
kind = Column(String(20), default="image")
|
||||
library_id = Column(Integer, ForeignKey("media_libraries.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
# Which backend holds the bytes; reads fall back to the volume either way.
|
||||
|
|
|
|||
|
|
@ -70,6 +70,9 @@ def _asset_json(asset: MediaAsset, tags: list[str]) -> dict:
|
|||
"title": asset.title,
|
||||
"caption": asset.caption,
|
||||
"alt_text": asset.alt_text,
|
||||
"source": asset.source,
|
||||
"source_url": asset.source_url,
|
||||
"overlay": asset.overlay,
|
||||
"kind": asset.kind,
|
||||
"library_id": asset.library_id,
|
||||
"category_id": asset.category_id,
|
||||
|
|
@ -191,6 +194,31 @@ def list_media(
|
|||
{**_asset_json(a, tags.get(a.id, [])), "used_by": used.get(a.id, 0)} for a in assets]}
|
||||
|
||||
|
||||
@router.get("/by-path")
|
||||
def media_by_path(path: str = Query(...), db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)):
|
||||
"""What is known about the image at this path: label, description, source.
|
||||
|
||||
The reader's viewer asks for this when a figure is opened, not when the page
|
||||
is drawn — a page of prose with six figures in it should cost six requests
|
||||
only if somebody opens all six.
|
||||
|
||||
Answers 404 rather than 403 for an image in a library this person cannot
|
||||
use: whether a private library holds a given filename is not a question
|
||||
this endpoint should answer.
|
||||
"""
|
||||
cleaned = (path or "").strip().lstrip("/")
|
||||
if cleaned.startswith("uploads/"):
|
||||
cleaned = cleaned[len("uploads/"):]
|
||||
asset = db.query(MediaAsset).filter(MediaAsset.path == cleaned).first()
|
||||
if not asset:
|
||||
raise HTTPException(404, "Image not found")
|
||||
scope = readable_libraries(db, current_user)
|
||||
if scope is not None and asset.library_id is not None and asset.library_id not in scope:
|
||||
raise HTTPException(404, "Image not found")
|
||||
return _asset_json(asset, _tags_for(db, [asset.id]).get(asset.id, []))
|
||||
|
||||
|
||||
@router.post("/upload", status_code=201)
|
||||
def upload_media(
|
||||
file: UploadFile = File(...),
|
||||
|
|
@ -198,6 +226,8 @@ def upload_media(
|
|||
title: str | None = Form(None),
|
||||
caption: str | None = Form(None),
|
||||
alt_text: str | None = Form(None),
|
||||
source: str | None = Form(None),
|
||||
source_url: str | None = Form(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
|
|
@ -246,6 +276,10 @@ class MediaUpdate(BaseModel):
|
|||
title: str | None = None
|
||||
caption: str | None = None
|
||||
alt_text: str | None = None
|
||||
source: str | None = None
|
||||
source_url: str | None = None
|
||||
#: Vector shapes in normalised coordinates; see docs/image-overlays.md.
|
||||
overlay: dict | None = None
|
||||
library_id: int | None = None
|
||||
category_id: int | None = None
|
||||
tags: list[str] | None = None
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import logging
|
|||
import time
|
||||
import os
|
||||
|
||||
from sqlalchemy import text as sa_text
|
||||
from sqlalchemy import or_, text as sa_text
|
||||
|
||||
from app.tasks import celery_app
|
||||
from app.database import SessionLocal
|
||||
|
|
@ -351,6 +351,131 @@ def extract_quiz(
|
|||
db.close()
|
||||
|
||||
|
||||
#: What each label means, in the prompt and in the docs, so the three words
|
||||
#: mean the same thing to the model, to the learner reading a filter, and to
|
||||
#: whoever disagrees with a label later.
|
||||
DIFFICULTY_RUBRIC = """easy — one step. The stem names a classic presentation and the answer is
|
||||
recall: a diagnosis with a pathognomonic finding, a first-line drug, a
|
||||
standard schedule. A prepared candidate answers without working anything out.
|
||||
medium — two steps, or one step under noise. The finding has to be interpreted
|
||||
before it can be used, a distractor is genuinely plausible, or the question
|
||||
asks for the *next* action rather than the diagnosis.
|
||||
hard — several steps, or a judgement. An atypical presentation, a decision
|
||||
where two answers are defensible and one is better, an exception to the rule
|
||||
the candidate learned, or arithmetic on top of interpretation."""
|
||||
|
||||
DIFFICULTY_PROMPT = """You are labelling pediatric board-exam questions by how hard they are to
|
||||
answer correctly, for a candidate who has studied the material.
|
||||
|
||||
Judge the *question*, not the topic. A rare disease with a give-away finding is
|
||||
easy; a common one where two managements are defensible is not.
|
||||
|
||||
""" + DIFFICULTY_RUBRIC + """
|
||||
|
||||
Return ONLY a JSON array, one object per question, no prose:
|
||||
[{"id": 12, "difficulty": "easy"}, ...]
|
||||
Every id you were given must appear exactly once.
|
||||
|
||||
QUESTIONS
|
||||
"""
|
||||
|
||||
#: Small enough that one bad reply costs little and the JSON stays inside the
|
||||
#: reply limit; large enough that 3,000 questions is a hundred calls and not
|
||||
#: three thousand.
|
||||
DIFFICULTY_BATCH = 25
|
||||
|
||||
|
||||
@celery_app.task(name="classify_question_difficulty", bind=True)
|
||||
def classify_question_difficulty(self, job_id: str = "", limit: int | None = None,
|
||||
relabel: bool = False) -> dict:
|
||||
"""Give every question a difficulty, in batches, resumably.
|
||||
|
||||
The column existed and was NULL on all 2,924 rows, which made the filter in
|
||||
the test builder a control that could only ever empty the bank, and made
|
||||
"move the session along the difficulty range" impossible to build. Nothing
|
||||
was ever going to write it by hand.
|
||||
|
||||
Resumable by construction: only rows with no difficulty are asked about
|
||||
unless `relabel` is set, so a run that dies halfway is continued by running
|
||||
it again. Each batch is committed on its own — a failure late in a long run
|
||||
keeps everything the earlier batches decided.
|
||||
"""
|
||||
from app.models.question import Question
|
||||
from app.services.ai_service import get_model_for_task, chat
|
||||
|
||||
r = _redis()
|
||||
if job_id:
|
||||
r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
|
||||
db = SessionLocal()
|
||||
labelled, failed_batches = 0, 0
|
||||
try:
|
||||
model_id, api_key = get_model_for_task(db, "keyword")
|
||||
query = db.query(Question).order_by(Question.id)
|
||||
if not relabel:
|
||||
query = query.filter(or_(Question.difficulty.is_(None), Question.difficulty == ""))
|
||||
rows = query.limit(limit).all() if limit else query.all()
|
||||
total = len(rows)
|
||||
if job_id:
|
||||
_push_step(r, job_id, "start", f"{total} questions to label")
|
||||
|
||||
for start in range(0, total, DIFFICULTY_BATCH):
|
||||
batch = rows[start:start + DIFFICULTY_BATCH]
|
||||
payload = []
|
||||
for question in batch:
|
||||
options = question.options if isinstance(question.options, list) else []
|
||||
payload.append({
|
||||
"id": question.id,
|
||||
# Trimmed: the shape of the reasoning is in the first
|
||||
# paragraph and the options, and a full vignette times
|
||||
# twenty-five is a reply nobody needs to pay for.
|
||||
"stem": (question.question_text or "")[:700],
|
||||
"options": [str(option)[:120] for option in options[:6]],
|
||||
"answer": (question.correct_answer or "")[:120],
|
||||
})
|
||||
prompt = DIFFICULTY_PROMPT + json.dumps(payload, ensure_ascii=False)
|
||||
try:
|
||||
raw = (chat(model=model_id, messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=1500, temperature=0, api_key=api_key) or "").strip()
|
||||
if raw.startswith("```"):
|
||||
raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:]
|
||||
raw = raw[:-3] if raw.endswith("```") else raw
|
||||
verdicts = json.loads(raw.strip())
|
||||
except Exception as exc:
|
||||
failed_batches += 1
|
||||
logger.warning("Difficulty batch at %s failed: %s", start, exc)
|
||||
if job_id:
|
||||
_push_step(r, job_id, "warn", f"A batch of {len(batch)} could not be read; skipped")
|
||||
continue
|
||||
|
||||
by_id = {question.id: question for question in batch}
|
||||
for verdict in verdicts if isinstance(verdicts, list) else []:
|
||||
question = by_id.get(verdict.get("id"))
|
||||
level = str(verdict.get("difficulty", "")).strip().lower()
|
||||
# Anything that is not one of the three words is not a label.
|
||||
# A model that answers "moderate" has not answered.
|
||||
if question is None or level not in ("easy", "medium", "hard"):
|
||||
continue
|
||||
question.difficulty = level
|
||||
labelled += 1
|
||||
db.commit()
|
||||
if job_id:
|
||||
_push_step(r, job_id, "batch",
|
||||
f"{min(start + DIFFICULTY_BATCH, total)} of {total} · {labelled} labelled")
|
||||
|
||||
if job_id:
|
||||
r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS)
|
||||
_push_step(r, job_id, "done", f"{labelled} questions labelled")
|
||||
return {"labelled": labelled, "considered": total, "failed_batches": failed_batches}
|
||||
except Exception as exc:
|
||||
logger.warning("Difficulty run failed: %s", exc)
|
||||
if job_id:
|
||||
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
|
||||
r.set(f"extraction:error:{job_id}", str(exc)[:300], ex=EXPIRE_SECONDS)
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(name="apply_topic_claims")
|
||||
def apply_topic_claims() -> dict:
|
||||
"""Re-apply every article's topic claim. The safety net, not the mechanism.
|
||||
|
|
|
|||
31
docs/TODO.md
31
docs/TODO.md
|
|
@ -88,7 +88,15 @@ Captured so nothing is lost while the article writing runs.
|
|||
that system exists to hold them, which is a classification gap rather
|
||||
than a filing error. `Radial Head Subluxation` merged into
|
||||
`Nursemaid's Elbow (Radial Head Subluxation)` — one idea under two names.
|
||||
- [x] **Adaptive session** — closed 2026-09-12, both remaining halves settled.
|
||||
- [ ] **Adaptive session: the difficulty ladder** — reopened 2026-09-12, hours
|
||||
after being closed as unbuildable, because the reason it was unbuildable
|
||||
is gone: every one of the 2,924 questions now carries a difficulty (622
|
||||
easy, 1,634 medium, 668 hard, no failures), labelled in batches of 25
|
||||
against a written rubric by `classify_question_difficulty`. The ordering
|
||||
can now move a session along the range as a learner does well or badly,
|
||||
which is the thing the design has always been missing. The rest of the
|
||||
item, below, stays closed.
|
||||
- [x] **Adaptive session (the other halves)** — closed 2026-09-12.
|
||||
*Shrunk readiness*: already done and now pinned by a test —
|
||||
`CandidateRanking.accuracy()` pulls a topic towards `NEUTRAL_RECALL` by
|
||||
`PRIOR_ANSWERS`, and the recommendations page sorts on `readiness` and
|
||||
|
|
@ -100,8 +108,8 @@ Captured so nothing is lost while the article writing runs.
|
|||
would be scoring noise while looking as though it worked. What was built
|
||||
instead is honesty in the control: the Difficulty facet now counts each
|
||||
level under the other filters and disables one that would empty the
|
||||
bank, so nobody picks Hard and watches the count fall to zero. Reopen
|
||||
when something actually writes that column.
|
||||
bank, so nobody picks Hard and watches the count fall to zero. **That
|
||||
column is now written** — see the reopened item above.
|
||||
|
||||
### Settings and access
|
||||
- [x] **Settings revamp** — done 2026-09-11. A section list beside one panel,
|
||||
|
|
@ -308,7 +316,22 @@ Captured so nothing is lost while the article writing runs.
|
|||
Pillow therefore means re-pinning litellm first, which is a deliberate
|
||||
upgrade of the AI layer and not a side effect to slip into a thumbnail
|
||||
change.
|
||||
- [ ] ~~Image thumbnails and caching~~ — you mentioned a tool from the ped-ai
|
||||
- [x] **Image thumbnails** — done, and the note was stale: `services/thumbnails.py`
|
||||
makes 256px and 640px WebP derivatives with Pillow (not sharp — sharp is
|
||||
Node and this backend is Python), stores them beside the original under
|
||||
`thumbs/{width}/{key}` in the same bucket, and `/uploads/...?w=256`
|
||||
serves them. Only those two widths exist, nothing is ever enlarged, and a
|
||||
derivative that cannot be made falls back to the original rather than
|
||||
failing the request. Every figure in prose now asks for the 256 and opens
|
||||
the original on a click.
|
||||
*Still open, deliberately:* HTTP caching. Uploads answer
|
||||
`Cache-Control: private, no-store`, so nothing is cached anywhere. A
|
||||
shared Caddy cache in front of access-controlled images would be a leak;
|
||||
what is safe is `private, max-age=…` on the derivatives, which is the
|
||||
browser's own cache and no one else's. And "make originals hard to pull"
|
||||
honestly means a signed short-lived URL per view — watermarking and
|
||||
right-click blocking are not that.
|
||||
- [ ] ~~Image caching through Caddy~~ — you mentioned a tool from the ped-ai
|
||||
work that generates thumbnails and caches through Caddy so images load
|
||||
fast, click to open full size, same bucket, and no straightforward
|
||||
download of the original. Not started: I need the name of that tool or a
|
||||
|
|
|
|||
60
docs/image-overlays.md
Normal file
60
docs/image-overlays.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# Image overlays
|
||||
|
||||
An educator can mark regions on an image — the lead lines on a knee film, the
|
||||
level of a narrowing, the border of a lesion — and a learner sees them only when
|
||||
they ask. This is the contract for how those marks are stored and drawn.
|
||||
|
||||
## Why vectors and not a second picture
|
||||
|
||||
The obvious implementation is a second PNG with the marks burnt in, shown
|
||||
instead of the original. It fails four ways: it cannot be turned off (which is
|
||||
the whole point — the learner should look first and check second), it cannot be
|
||||
corrected without redrawing, it doubles the storage for every marked image, and
|
||||
it is correct at exactly one size. Vectors have none of those problems and draw
|
||||
as crisply on a phone as on a monitor.
|
||||
|
||||
## The shape of the data
|
||||
|
||||
`media_assets.overlay`, JSON, null when nothing is marked:
|
||||
|
||||
```json
|
||||
{
|
||||
"shapes": [
|
||||
{
|
||||
"kind": "path",
|
||||
"points": [[0.31, 0.52], [0.34, 0.51], [0.38, 0.53]],
|
||||
"color": "#5eead4",
|
||||
"width": 0.006,
|
||||
"label": "Dense metaphyseal bands"
|
||||
},
|
||||
{ "kind": "rect", "x": 0.12, "y": 0.4, "w": 0.2, "h": 0.15, "color": "#5eead4" },
|
||||
{ "kind": "ellipse", "cx": 0.5, "cy": 0.5, "rx": 0.1, "ry": 0.08, "color": "#f0a53d" },
|
||||
{ "kind": "arrow", "points": [[0.2, 0.2], [0.4, 0.35]], "color": "#5eead4" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Every coordinate is normalised to 0–1** against the image's own width and
|
||||
height — never pixels. That is what makes one drawing correct in a 256px
|
||||
thumbnail, in the viewer, and on a projector. `width` is normalised too, against
|
||||
the image width, so a stroke stays the same relative weight.
|
||||
|
||||
Four kinds, and no more without a reason: `path` (freehand or polyline, the one
|
||||
that traces an anatomical edge), `rect`, `ellipse`, `arrow` (a `path` of exactly
|
||||
two points, drawn with a head). `label` is optional on any shape and is what a
|
||||
screen reader is given.
|
||||
|
||||
## Drawing it
|
||||
|
||||
An SVG with `viewBox="0 0 1 1"` and `preserveAspectRatio="none"`, absolutely
|
||||
positioned over the image at the same size. Because the viewBox is the unit
|
||||
square, the stored numbers are the SVG's own coordinates and no conversion is
|
||||
needed at any size.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Off by default.** The learner sees the image, and turns the overlay on.
|
||||
Marks shown before the learner has looked answer the question for them.
|
||||
- **The original is never modified.** Nothing here writes to the image file.
|
||||
- **An overlay is not a caption.** What the marks *mean* belongs in the
|
||||
description; the overlay says where.
|
||||
75
frontend/src/components/ImageFigure.css
Normal file
75
frontend/src/components/ImageFigure.css
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/* Small until asked for. The caption is the author's own alt text, so what a
|
||||
reader sees under the figure and what a screen reader is told are the same
|
||||
sentence — and there is no second field to keep in step with the first. */
|
||||
.imgfig { display: inline-flex; flex-direction: column; gap: 5px; margin: 10px 12px 10px 0; vertical-align: top; max-width: 100%; }
|
||||
.imgfig-thumb {
|
||||
display: block; padding: 0; cursor: zoom-in; line-height: 0;
|
||||
border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
.imgfig-thumb:hover { border-color: var(--primary); }
|
||||
.imgfig-thumb img { display: block; max-width: 260px; max-height: 200px; width: auto; height: auto; }
|
||||
.imgfig-label { max-width: 260px; font-size: 0.78rem; line-height: 1.4; color: var(--text-muted); }
|
||||
|
||||
/* ── The viewer ───────────────────────────────────────────────────────
|
||||
The image gets the screen, and what is known about it sits beside it. Dark,
|
||||
because a radiograph read against a white page is read through glare. */
|
||||
.imgfig-viewer {
|
||||
position: fixed; inset: 0; z-index: 1200;
|
||||
display: flex; flex-direction: column;
|
||||
background: #0b1220; color: #e2e8f0;
|
||||
}
|
||||
.imgfig-bar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 14px; border-bottom: 1px solid rgba(255, 255, 255, .1);
|
||||
}
|
||||
.imgfig-tool {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 6px 13px; cursor: pointer; font: inherit; font-size: 0.83rem; font-weight: 600;
|
||||
border: 1px solid rgba(255, 255, 255, .22); border-radius: 8px;
|
||||
background: transparent; color: #e2e8f0;
|
||||
}
|
||||
.imgfig-tool:hover { border-color: #5eead4; color: #5eead4; }
|
||||
.imgfig-tool.is-on { border-color: #5eead4; color: #0b1220; background: #5eead4; }
|
||||
.imgfig-close {
|
||||
margin-left: auto; width: 34px; height: 34px; cursor: pointer;
|
||||
border: 0; border-radius: 8px; background: transparent; color: #e2e8f0; font-size: 1rem;
|
||||
}
|
||||
.imgfig-close:hover { background: rgba(255, 255, 255, .12); }
|
||||
|
||||
.imgfig-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 320px minmax(0, 1fr); }
|
||||
.imgfig-desc {
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
padding: 26px 24px; overflow-y: auto;
|
||||
border-right: 1px solid rgba(255, 255, 255, .1);
|
||||
}
|
||||
.imgfig-desc strong { font-size: 1.05rem; font-weight: 700; }
|
||||
.imgfig-desc-text { font-size: 0.92rem; line-height: 1.6; color: #cbd5e1; }
|
||||
.imgfig-source { font-size: 0.78rem; line-height: 1.55; color: #94a3b8; }
|
||||
.imgfig-source a { color: #94a3b8; }
|
||||
|
||||
.imgfig-stage {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 20px; overflow: auto; cursor: zoom-out;
|
||||
}
|
||||
/* The frame is what the overlay is positioned against, so it must be exactly
|
||||
the size of the drawn image and no larger — hence a block that shrinks to
|
||||
its content rather than a flex child that stretches. */
|
||||
.imgfig-frame { position: relative; display: block; line-height: 0; cursor: default; }
|
||||
.imgfig-frame img { display: block; max-width: 100%; max-height: calc(100dvh - 110px); width: auto; height: auto; }
|
||||
.imgov { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
/* Stacked, description first: it can be read before scrolling to the
|
||||
picture rather than after it, and on a phone the picture wants the width
|
||||
more than the text does. */
|
||||
.imgfig-body { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); overflow-y: auto; }
|
||||
.imgfig-desc { border-right: 0; border-bottom: 1px solid rgba(255, 255, 255, .1); padding: 16px 16px 14px; gap: 10px; }
|
||||
.imgfig-stage { padding: 12px; }
|
||||
.imgfig-frame img { max-height: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.imgfig { margin-right: 0; }
|
||||
.imgfig-thumb img, .imgfig-label { max-width: 100%; }
|
||||
}
|
||||
116
frontend/src/components/ImageFigure.jsx
Normal file
116
frontend/src/components/ImageFigure.jsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
import { uploadUrl } from '../utils/uploads'
|
||||
import ImageOverlay from './ImageOverlay'
|
||||
import './ImageFigure.css'
|
||||
|
||||
/**
|
||||
* A figure in prose: small until you ask for it, and then the whole screen.
|
||||
*
|
||||
* Every image written into markdown — an article's figure, the illustration on
|
||||
* an option's explanation, a diagram in a lesson — used to render at whatever
|
||||
* width it happened to be, which on a 2,000px radiograph meant a wall of
|
||||
* greyscale in the middle of a sentence and four megabytes to draw it. A
|
||||
* reader wants to know a figure is there, and then to look at it properly.
|
||||
*
|
||||
* So: a 256px derivative with the author's label under it, and on a click a
|
||||
* viewer that gives the image the screen and puts what is known about it
|
||||
* beside it — what it shows, and where it came from. What the bank knows is
|
||||
* asked for when the figure is opened rather than when the page is drawn: six
|
||||
* figures in an article should cost six requests only if somebody opens all
|
||||
* six.
|
||||
*
|
||||
* Anything an educator has marked on the image is off until the learner turns
|
||||
* it on. Marks shown before they have looked answer the question for them.
|
||||
*/
|
||||
export default function ImageFigure({ src, alt = '', attemptId, className = '' }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [asset, setAsset] = useState(null)
|
||||
const [marked, setMarked] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
const onKey = event => { if (event.key === 'Escape') setOpen(false) }
|
||||
document.addEventListener('keydown', onKey)
|
||||
// The page behind must not scroll while the viewer has the screen.
|
||||
const previous = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.body.style.overflow = previous
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || asset !== null || !src) return undefined
|
||||
let live = true
|
||||
api.get('/media/by-path', { params: { path: src } })
|
||||
// `false` rather than null: asked and there is nothing, so do not ask again.
|
||||
.then(res => { if (live) setAsset(res.data || false) })
|
||||
.catch(() => { if (live) setAsset(false) })
|
||||
return () => { live = false }
|
||||
}, [open, asset, src])
|
||||
|
||||
if (!src) return null
|
||||
const label = (alt || '').trim()
|
||||
const title = (asset && asset.title) || label
|
||||
const description = (asset && (asset.caption || asset.alt_text)) || (title === label ? '' : label)
|
||||
const overlay = asset && asset.overlay
|
||||
const hasShapes = !!(overlay && Array.isArray(overlay.shapes) && overlay.shapes.length)
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={`imgfig ${className}`.trim()}>
|
||||
<button type="button" className="imgfig-thumb" onClick={() => setOpen(true)}
|
||||
aria-label={label ? `Open the figure: ${label}` : 'Open the figure'}>
|
||||
{/* Lazy: a page of prose with six figures should not fetch six
|
||||
images to show the first paragraph. */}
|
||||
<img src={uploadUrl(src, attemptId, 256)} alt={label} loading="lazy" decoding="async" />
|
||||
</button>
|
||||
{label && <span className="imgfig-label">{label}</span>}
|
||||
</span>
|
||||
|
||||
{open && (
|
||||
<span className="imgfig-viewer" role="dialog" aria-modal="true" aria-label={title || 'Figure'}>
|
||||
<span className="imgfig-bar">
|
||||
{hasShapes && (
|
||||
<button type="button" className={`imgfig-tool${marked ? ' is-on' : ''}`}
|
||||
aria-pressed={marked} onClick={() => setMarked(v => !v)}>
|
||||
◫ Overlay
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="imgfig-close" aria-label="Close"
|
||||
onClick={() => setOpen(false)}>✕</button>
|
||||
</span>
|
||||
|
||||
<span className="imgfig-body">
|
||||
{/* The description beside the image on a wide screen, above it on a
|
||||
narrow one — where it can be read before scrolling to the
|
||||
picture, rather than after it. */}
|
||||
{(title || description || (asset && asset.source)) && (
|
||||
<span className="imgfig-desc">
|
||||
{title && <strong>{title}</strong>}
|
||||
{description && <span className="imgfig-desc-text">{description}</span>}
|
||||
{asset && asset.source && (
|
||||
<span className="imgfig-source">
|
||||
Source: {asset.source_url
|
||||
? <a href={asset.source_url} target="_blank" rel="noopener noreferrer">{asset.source}</a>
|
||||
: asset.source}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="imgfig-stage" onClick={event => {
|
||||
if (event.target === event.currentTarget) setOpen(false)
|
||||
}}>
|
||||
<span className="imgfig-frame">
|
||||
<img src={uploadUrl(src, attemptId)} alt={title || label} />
|
||||
{marked && hasShapes && <ImageOverlay overlay={overlay} />}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
53
frontend/src/components/ImageOverlay.jsx
Normal file
53
frontend/src/components/ImageOverlay.jsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* The regions an educator has marked on an image.
|
||||
*
|
||||
* An SVG on the unit square laid over the picture: because the viewBox is
|
||||
* `0 0 1 1` and the stored coordinates are normalised, the numbers in the
|
||||
* database *are* the SVG's coordinates, and one drawing is correct in a
|
||||
* thumbnail, in the viewer and on a projector with no conversion anywhere.
|
||||
*
|
||||
* See `docs/image-overlays.md` for the shape of the data.
|
||||
*/
|
||||
const DEFAULT_COLOR = '#5eead4'
|
||||
const DEFAULT_WIDTH = 0.006
|
||||
|
||||
const points = (shape) => (Array.isArray(shape.points) ? shape.points : [])
|
||||
.filter(point => Array.isArray(point) && point.length === 2)
|
||||
|
||||
export default function ImageOverlay({ overlay }) {
|
||||
const shapes = (overlay && Array.isArray(overlay.shapes)) ? overlay.shapes : []
|
||||
if (!shapes.length) return null
|
||||
|
||||
return (
|
||||
<svg className="imgov" viewBox="0 0 1 1" preserveAspectRatio="none" aria-hidden="true">
|
||||
<defs>
|
||||
<marker id="imgov-head" viewBox="0 0 10 10" refX="8" refY="5"
|
||||
markerWidth="5" markerHeight="5" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="context-stroke" />
|
||||
</marker>
|
||||
</defs>
|
||||
{shapes.map((shape, index) => {
|
||||
const stroke = shape.color || DEFAULT_COLOR
|
||||
const width = Number(shape.width) > 0 ? Number(shape.width) : DEFAULT_WIDTH
|
||||
const common = {
|
||||
key: index, stroke, strokeWidth: width, fill: 'none',
|
||||
strokeLinecap: 'round', strokeLinejoin: 'round',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
}
|
||||
if (shape.kind === 'rect') {
|
||||
return <rect {...common} x={shape.x} y={shape.y} width={shape.w} height={shape.h} rx={0.008} />
|
||||
}
|
||||
if (shape.kind === 'ellipse') {
|
||||
return <ellipse {...common} cx={shape.cx} cy={shape.cy} rx={shape.rx} ry={shape.ry} />
|
||||
}
|
||||
const list = points(shape)
|
||||
if (list.length < 2) return null
|
||||
const d = list.map(([x, y], i) => `${i ? 'L' : 'M'} ${x} ${y}`).join(' ')
|
||||
if (shape.kind === 'arrow') {
|
||||
return <path {...common} d={d} markerEnd="url(#imgov-head)" />
|
||||
}
|
||||
return <path {...common} d={d} />
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import rehypeHighlightOffsets from '../utils/highlightOffsets'
|
|||
import remarkTipTerms from '../utils/tipTerms'
|
||||
import remarkKeyPoints from '../utils/keyPoints'
|
||||
import TipTerm from './TipTerm'
|
||||
import { markdownImageUrl } from '../utils/uploads'
|
||||
import ImageFigure from './ImageFigure'
|
||||
import './RichText.css'
|
||||
|
||||
/**
|
||||
|
|
@ -81,7 +81,12 @@ export default function RichText({
|
|||
// type every render, and React unmounts and remounts everything it drew — so
|
||||
// an open tip closed itself each time the exam clock ticked.
|
||||
const components = useMemo(() => ({
|
||||
img: ({ node, src, ...props }) => <img {...props} src={markdownImageUrl(src, attemptId)} />,
|
||||
// Every figure in prose is a thumbnail with the author's label under it,
|
||||
// full size on a click. A 2,000px radiograph rendered inline was a wall of
|
||||
// greyscale in the middle of a sentence, and four megabytes to draw it.
|
||||
img: ({ node, src, alt, ...props }) => (
|
||||
<ImageFigure src={src} alt={alt} attemptId={attemptId} />
|
||||
),
|
||||
// `{{phrase|tip}}` — a teaching point that opens where the phrase is.
|
||||
span: ({ node, children, ...props }) => (
|
||||
props.className === 'tip-term'
|
||||
|
|
|
|||
|
|
@ -3,9 +3,14 @@ import { render, screen, within } from '@testing-library/react'
|
|||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import RichText from './RichText'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(() => Promise.resolve({ data: {} })) } }))
|
||||
vi.mock('../utils/uploads', () => ({ markdownImageUrl: (src) => `/uploads/${src}` }))
|
||||
vi.mock('../utils/uploads', () => ({
|
||||
markdownImageUrl: (src) => `/uploads/${src}`,
|
||||
uploadUrl: (src, attemptId, width) => `/uploads/${src}${width ? `?w=${width}` : ''}`,
|
||||
THUMB_WIDTHS: [256, 640],
|
||||
}))
|
||||
|
||||
const mount = (props) => render(<MemoryRouter><RichText {...props} /></MemoryRouter>)
|
||||
|
||||
|
|
@ -161,3 +166,60 @@ describe('==key points==', () => {
|
|||
expect(container.querySelector('mark')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('figures in prose', () => {
|
||||
it('renders a thumbnail with the label under it, and the full image on a click', async () => {
|
||||
const { container } = mount({ value: '' })
|
||||
const thumb = container.querySelector('.imgfig-thumb img')
|
||||
// A derivative, not the original: a 2MB radiograph to draw a postage stamp
|
||||
// is what this exists to stop.
|
||||
expect(thumb).toHaveAttribute('src', expect.stringContaining('w=256'))
|
||||
expect(thumb).toHaveAttribute('alt', 'Lateral neck film showing the thumb sign')
|
||||
// The author's own alt text is the caption, so the sighted reader and the
|
||||
// screen reader are told the same thing.
|
||||
expect(screen.getByText('Lateral neck film showing the thumb sign')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Open the figure/ }))
|
||||
const viewer = screen.getByRole('dialog')
|
||||
// Full size in the viewer — no width asked for.
|
||||
expect(within(viewer).getByRole('img')).toHaveAttribute('src', expect.not.stringContaining('w='))
|
||||
await userEvent.click(within(viewer).getByRole('button', { name: 'Close' }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves an unlabelled figure without a caption', () => {
|
||||
const { container } = mount({ value: '' })
|
||||
expect(container.querySelector('.imgfig-thumb')).toBeInTheDocument()
|
||||
expect(container.querySelector('.imgfig-label')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the figure viewer', () => {
|
||||
it('shows what the bank knows: the label, the description and the source', async () => {
|
||||
api.get.mockResolvedValue({ data: {
|
||||
title: 'Lead poisoning', caption: 'X-ray of both knees showing dense metaphyseal bands.',
|
||||
source: 'Ying et al., BMC Pediatrics, CC BY 4.0', source_url: 'https://example.test/paper',
|
||||
overlay: { shapes: [{ kind: 'path', points: [[0.3, 0.5], [0.6, 0.5]] }] },
|
||||
} })
|
||||
mount({ value: '' })
|
||||
await userEvent.click(screen.getByRole('button', { name: /Open the figure/ }))
|
||||
const viewer = await screen.findByRole('dialog', { name: 'Lead poisoning' })
|
||||
expect(within(viewer).getByText('X-ray of both knees showing dense metaphyseal bands.')).toBeInTheDocument()
|
||||
expect(within(viewer).getByRole('link', { name: /Ying et al/ })).toHaveAttribute('href', 'https://example.test/paper')
|
||||
|
||||
// What an educator marked is off until it is asked for: marks shown before
|
||||
// the learner has looked answer the question for them.
|
||||
expect(document.querySelector('.imgov')).toBeNull()
|
||||
await userEvent.click(within(viewer).getByRole('button', { name: /Overlay/ }))
|
||||
expect(document.querySelector('.imgov path')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers no overlay control when nothing has been marked', async () => {
|
||||
api.get.mockResolvedValue({ data: { title: 'A plain figure' } })
|
||||
mount({ value: '' })
|
||||
await userEvent.click(screen.getByRole('button', { name: /Open the figure/ }))
|
||||
const viewer = await screen.findByRole('dialog')
|
||||
expect(within(viewer).queryByRole('button', { name: /Overlay/ })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue