first commit
This commit is contained in:
commit
24fa9a2d77
15 changed files with 1870 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
.make.*
|
||||
elm-stuff/
|
||||
error.txt
|
||||
*.exam
|
||||
app.js
|
||||
numbas.js
|
2
.watchmakerc
Normal file
2
.watchmakerc
Normal file
|
@ -0,0 +1,2 @@
|
|||
extensions:
|
||||
- .elm
|
11
Makefile
Normal file
11
Makefile
Normal file
|
@ -0,0 +1,11 @@
|
|||
DIRNAME=$(notdir $(CURDIR))
|
||||
|
||||
ELMS=$(wildcard src/*.elm)
|
||||
|
||||
app.js: src/App.elm $(ELMS)
|
||||
-elm make $< --output=$@ 2> error.txt
|
||||
@cat error.txt
|
||||
|
||||
upload: app.js index.html style.css
|
||||
rsync -avz . clpland:~/domains/somethingorotherwhatever.com/html/$(DIRNAME)
|
||||
@echo "Uploaded to https://somethingorotherwhatever.com/$(DIRNAME)"
|
1
TODO
Normal file
1
TODO
Normal file
|
@ -0,0 +1 @@
|
|||
* Correctly do MathJax inside custom elements so the sizing is correct and the CSS is copied properly.
|
4
demo.mjs
Normal file
4
demo.mjs
Normal file
|
@ -0,0 +1,4 @@
|
|||
import './load-app.js';
|
||||
|
||||
numbas_embedder.add_exam('lots-of-instances', 'exam-1009-lots-of-instances-theme.exam');
|
||||
numbas_embedder.add_exam('logarithms', 'https://numbas.mathcentre.ac.uk/exam/5754/logarithms.exam');
|
25
elm.json
Normal file
25
elm.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"type": "application",
|
||||
"source-directories": [
|
||||
"src"
|
||||
],
|
||||
"elm-version": "0.19.1",
|
||||
"dependencies": {
|
||||
"direct": {
|
||||
"NoRedInk/elm-json-decode-pipeline": "1.0.1",
|
||||
"elm/browser": "1.0.2",
|
||||
"elm/core": "1.0.5",
|
||||
"elm/html": "1.0.0",
|
||||
"elm/json": "1.1.3"
|
||||
},
|
||||
"indirect": {
|
||||
"elm/time": "1.0.0",
|
||||
"elm/url": "1.0.0",
|
||||
"elm/virtual-dom": "1.0.3"
|
||||
}
|
||||
},
|
||||
"test-dependencies": {
|
||||
"direct": {},
|
||||
"indirect": {}
|
||||
}
|
||||
}
|
45
index.html
Normal file
45
index.html
Normal file
|
@ -0,0 +1,45 @@
|
|||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>Numbas Elm theme</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
|
||||
<script src="mathjax-config.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"></script>
|
||||
|
||||
<!-- Numbas runtime -->
|
||||
<script src="numbas.js"></script>
|
||||
|
||||
<!-- JS Numbas display code -->
|
||||
<script src="numbas-elm-display.js"></script>
|
||||
|
||||
<!-- Elm Numbas display code -->
|
||||
<script src="app.js"></script>
|
||||
|
||||
<!-- Load exams -->
|
||||
<script src="demo.mjs" type="module"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
<section>
|
||||
<h1>Logarithms exam</h1>
|
||||
|
||||
<numbas-part exam="logarithms" mode="part" question="0" part="p0"></numbas-part>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h1>Lots of instances exam</h1>
|
||||
<h2>Question 1</h2>
|
||||
<numbas-part exam="lots-of-instances" mode="question" question="1" part="p0"></numbas-part>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>Numbas??</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
136
load-app.js
Normal file
136
load-app.js
Normal file
|
@ -0,0 +1,136 @@
|
|||
import show_error from './show-error.mjs';
|
||||
console.clear();
|
||||
|
||||
(async () => {
|
||||
const compilation_error = await show_error;
|
||||
if(compilation_error) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
class NumbasEmbedder {
|
||||
constructor() {
|
||||
this.exams = {};
|
||||
|
||||
this.numbas_promise = new Promise(resolve => {
|
||||
Numbas.queueScript('go', ['start-exam', 'display'], async () => {
|
||||
Numbas.display.init();
|
||||
resolve(Numbas);
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
get_exam(id) {
|
||||
if(!this.exams[id]) {
|
||||
const exam_ref = this.exams[id] = {}
|
||||
exam_ref.promise = new Promise((resolve,reject) => {
|
||||
exam_ref.resolve = resolve;
|
||||
exam_ref.reject = reject;
|
||||
});
|
||||
}
|
||||
|
||||
return this.exams[id];
|
||||
}
|
||||
|
||||
async add_exam(id, source_url) {
|
||||
console.log('add exam',id, source_url);
|
||||
await this.numbas_promise;
|
||||
console.log('numbas loaded', id);
|
||||
const res = await fetch(source_url);
|
||||
const exam_file = await res.text();
|
||||
const exam_json = JSON.parse(exam_file.slice(exam_file.indexOf('\n')))
|
||||
const exam = window.exam = Numbas.createExamFromJSON(exam_json);
|
||||
|
||||
exam.display_id = id;
|
||||
exam.init();
|
||||
|
||||
exam.signals.on(['ready', 'display question list initialised'], () => {
|
||||
console.info('exam ready', id);
|
||||
Numbas.signals.trigger('exam ready');
|
||||
exam.begin();
|
||||
|
||||
const exam_promise = this.get_exam(id);
|
||||
console.log("LOADED", id);
|
||||
exam_promise.resolve(exam);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const numbas_embedder = window.numbas_embedder = new NumbasEmbedder();
|
||||
|
||||
class ElmHTMLElement extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({mode:'open'});
|
||||
}
|
||||
static get observedAttributes() { return ['html'] };
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
if(name == 'html') {
|
||||
this.html = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
set html(value) {
|
||||
if(typeof value == 'string') {
|
||||
this.shadowRoot.innerHTML = value;
|
||||
} else {
|
||||
this.shadowRoot.innerHTML = '';
|
||||
this.shadowRoot.append(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
customElements.define('elm-html', ElmHTMLElement);
|
||||
|
||||
class NumbasPartElement extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.attachShadow({mode:'open'});
|
||||
|
||||
const style = document.createElement('link');
|
||||
style.rel = 'stylesheet';
|
||||
style.href = 'numbas-part.css';
|
||||
this.shadowRoot.append(style);
|
||||
|
||||
this.init_app();
|
||||
}
|
||||
|
||||
async init_app() {
|
||||
const exam_id = this.getAttribute('exam');
|
||||
const mode = this.getAttribute('mode');
|
||||
const questionNumber = parseInt(this.getAttribute('question'));
|
||||
const partPath = this.getAttribute('part');
|
||||
|
||||
const exam = await numbas_embedder.get_exam(exam_id).promise;
|
||||
const container = document.createElement('div');
|
||||
this.shadowRoot.append(container);
|
||||
const app = Elm.App.init({
|
||||
node: container,
|
||||
flags: {exam: exam, view_mode: {mode, questionNumber, partPath}}
|
||||
});
|
||||
|
||||
|
||||
const message_handlers = {
|
||||
'question': ({questionNumber, msg}) => {
|
||||
const question = exam.questionList[questionNumber];
|
||||
question.display.handle_message(msg);
|
||||
}
|
||||
}
|
||||
|
||||
app.ports.sendMessage.subscribe(data => {
|
||||
const fn = message_handlers[data.msgtype];
|
||||
if(fn) {
|
||||
fn(data);
|
||||
}
|
||||
})
|
||||
|
||||
exam.events.on('update app', ({type, arg}) => {
|
||||
//type != 'showTiming' && console.log('update app', {type,arg});
|
||||
app.ports.receiveMessage.send({type, arg});
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('numbas-part', NumbasPartElement);
|
10
mathjax-config.js
Normal file
10
mathjax-config.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
MathJax = {
|
||||
tex: {
|
||||
inlineMath: [['$', '$'], ['\\(', '\\)']],
|
||||
packages: {'[+]': ['numbas']}
|
||||
},
|
||||
loader: {
|
||||
load: ['[custom]/numbas-mathjax.js'],
|
||||
paths: {custom: '.'}
|
||||
}
|
||||
};
|
1060
numbas-elm-display.js
Normal file
1060
numbas-elm-display.js
Normal file
File diff suppressed because it is too large
Load diff
64
numbas-mathjax.js
Normal file
64
numbas-mathjax.js
Normal file
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* An extension to MathJax which adds \var and \simplify commands.
|
||||
*/
|
||||
|
||||
|
||||
(function() {
|
||||
|
||||
var NumbasMap = new MathJax._.input.tex.SymbolMap.CommandMap(
|
||||
'numbasMap',
|
||||
|
||||
{
|
||||
var: ['numbasVar', 'var'],
|
||||
simplify: ['numbasToken', 'simplify']
|
||||
},
|
||||
|
||||
{
|
||||
numbasVar: function mmlToken(parser, name, type) {
|
||||
const {jme} = Numbas;
|
||||
|
||||
try {
|
||||
const settings_string = parser.GetBrackets(name); // The optional argument to the command, in square brackets.
|
||||
|
||||
const settings = {};
|
||||
if(settings_string!==undefined) {
|
||||
settings_string.split(/\s*,\s*/g).forEach(function(v) {
|
||||
var setting = jme.normaliseRulesetName(v.trim());
|
||||
settings[setting] = true;
|
||||
});
|
||||
}
|
||||
|
||||
const expr = parser.GetArgument(name);
|
||||
|
||||
const {scope} = parser.configuration.packageData.get('numbas');
|
||||
|
||||
const tok = jme.evaluate(expr, scope);
|
||||
const tex = jme.display.texify({tok}, settings, scope);
|
||||
const mml = new MathJax._.input.tex.TexParser.default(tex, parser.stack.env, parser.configuration).mml();
|
||||
|
||||
parser.Push(mml);
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
throw(new Numbas.Error('mathjax.math processing error',{message:e.message,expression:expr}));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function saveJMEScope(arg) {
|
||||
const scope = Numbas.display.find_jme_scope(arg.math.start.node);
|
||||
arg.data.packageData.set('numbas',{scope});
|
||||
}
|
||||
|
||||
|
||||
var NumbasConfiguration = MathJax._.input.tex.Configuration.Configuration.create('numbas', {
|
||||
handler: {
|
||||
macro: ['numbasMap']
|
||||
},
|
||||
preprocessors: [
|
||||
[saveJMEScope, 1]
|
||||
],
|
||||
});
|
||||
|
||||
})();
|
3
numbas-part.css
Normal file
3
numbas-part.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
form {
|
||||
display: inline;
|
||||
}
|
21
show-error.mjs
Normal file
21
show-error.mjs
Normal file
|
@ -0,0 +1,21 @@
|
|||
export default fetch('/error.txt').then(r=>{
|
||||
if(r.ok) {
|
||||
return r.text();
|
||||
} else {
|
||||
throw('');
|
||||
}
|
||||
}).then(text => {
|
||||
if(!text) {
|
||||
return false;
|
||||
}
|
||||
document.body.innerHTML = '';
|
||||
const error_show = document.createElement('pre');
|
||||
error_show.setAttribute('id','build-error');
|
||||
error_show.style.background = 'black';
|
||||
error_show.style.color = 'white';
|
||||
error_show.style.padding = '1em';
|
||||
error_show.style['font-size'] = '16px';
|
||||
error_show.textContent = text;
|
||||
document.body.appendChild(error_show);
|
||||
return true;
|
||||
}).catch(e => false);
|
479
src/App.elm
Normal file
479
src/App.elm
Normal file
|
@ -0,0 +1,479 @@
|
|||
port module App exposing (..)
|
||||
|
||||
import Browser
|
||||
import Dict exposing (Dict)
|
||||
import Html as H exposing (Html)
|
||||
import Html.Keyed
|
||||
import Html.Attributes as HA
|
||||
import Html.Events as HE
|
||||
import Json.Decode as JD
|
||||
import Json.Decode.Pipeline as JDP exposing (required, requiredAt)
|
||||
import Json.Encode as JE exposing (Value)
|
||||
import Tuple exposing (pair, first, second)
|
||||
|
||||
port sendMessage : Value -> Cmd msg
|
||||
port receiveMessage : (Value -> msg) -> Sub msg
|
||||
port receiveClick : (Value -> msg) -> Sub msg
|
||||
|
||||
main = Browser.element
|
||||
{ init = init
|
||||
, update = update
|
||||
, subscriptions = subscriptions
|
||||
, view = view
|
||||
}
|
||||
|
||||
type alias Exam =
|
||||
{ object : JE.Value
|
||||
, currentQuestion : Int
|
||||
, questions : List Question
|
||||
}
|
||||
|
||||
type alias Question =
|
||||
{ isDirty : Bool
|
||||
, score : Float
|
||||
, object : JE.Value
|
||||
, name : String
|
||||
, parts : List Part
|
||||
, number : Int
|
||||
, statement : JE.Value
|
||||
}
|
||||
|
||||
type alias QuestionMessage =
|
||||
{ questionNumber : Int
|
||||
, type_ : String
|
||||
, msg : JE.Value
|
||||
}
|
||||
|
||||
type PartType
|
||||
= InformationOnlyPart
|
||||
| NumberEntryPart
|
||||
| MathematicalExpressionPart
|
||||
| PatternMatchPart
|
||||
| GapFillPart
|
||||
| CustomPart String
|
||||
|
||||
type alias Part =
|
||||
{ object : JE.Value
|
||||
, name : String
|
||||
, type_ : PartType
|
||||
, path : String
|
||||
, score : Float
|
||||
, marks: Float
|
||||
, prompt : JE.Value
|
||||
, marking_feedback : List FeedbackMessage
|
||||
, gaps : Gaps
|
||||
}
|
||||
|
||||
type Gaps = Gaps (List Part)
|
||||
|
||||
type alias FeedbackMessage =
|
||||
{ op : String
|
||||
, credit : Maybe Float
|
||||
, message : JE.Value
|
||||
, reason : Maybe String
|
||||
, credit_change : Maybe String
|
||||
}
|
||||
|
||||
type alias PartMessage =
|
||||
{ partPath : String
|
||||
, type_ : String
|
||||
, msg : JE.Value
|
||||
}
|
||||
|
||||
type ViewMode
|
||||
= ViewWholeExam
|
||||
| ViewQuestion Int
|
||||
| ViewPart Int String
|
||||
| ViewPartAnswer Int String
|
||||
|
||||
decode_default : JD.Decoder a -> a -> JE.Value -> a
|
||||
decode_default decoder default =
|
||||
JD.decodeValue decoder >> Result.withDefault default
|
||||
|
||||
decode_view_mode =
|
||||
JD.field "mode" JD.string
|
||||
|> JD.andThen (\mode -> case mode of
|
||||
"exam" -> JD.succeed ViewWholeExam
|
||||
"question" -> JD.field "questionNumber" JD.int |> JD.map ViewQuestion
|
||||
"part" ->
|
||||
JD.map2
|
||||
ViewPart
|
||||
(JD.field "questionNumber" JD.int)
|
||||
(JD.field "partPath" JD.string)
|
||||
"part_answer" ->
|
||||
JD.map2
|
||||
ViewPartAnswer
|
||||
(JD.field "questionNumber" JD.int)
|
||||
(JD.field "partPath" JD.string)
|
||||
_ -> JD.fail <| "Unrecognised view mode: "++mode
|
||||
)
|
||||
|
||||
type alias Model =
|
||||
{ exam : Maybe Exam
|
||||
, messages : List String
|
||||
, view_mode : ViewMode
|
||||
}
|
||||
|
||||
init_model : JE.Value -> Model
|
||||
init_model flags =
|
||||
let
|
||||
exam =
|
||||
JD.decodeValue (JD.field "exam" JD.value) flags
|
||||
|> Result.toMaybe
|
||||
|> Maybe.map (\e ->
|
||||
{ object = e
|
||||
, currentQuestion = -1
|
||||
, questions = get_questions e
|
||||
}
|
||||
)
|
||||
|
||||
view_mode =
|
||||
JD.decodeValue
|
||||
(JD.field "view_mode" decode_view_mode)
|
||||
flags
|
||||
|> Result.withDefault ViewWholeExam
|
||||
in
|
||||
{ exam = exam
|
||||
, messages = []
|
||||
, view_mode = view_mode
|
||||
}
|
||||
|
||||
type Msg
|
||||
= ReceiveMessage Value
|
||||
| SendPartMessage Question Part String (List (String, JE.Value))
|
||||
|
||||
init : JE.Value -> (Model, Cmd msg)
|
||||
init flags = (init_model flags, Cmd.none)
|
||||
|
||||
listGet : Int -> List a -> Maybe a
|
||||
listGet i = List.drop i >> List.head
|
||||
|
||||
decode_message =
|
||||
JD.map2 pair
|
||||
(JD.field "type" JD.string)
|
||||
(JD.field "arg" JD.value)
|
||||
|
||||
update msg model = case msg of
|
||||
ReceiveMessage value ->
|
||||
case JD.decodeValue decode_message value of
|
||||
Ok (kind,arg) -> ({ model | messages = if kind /= "showTiming" then kind::model.messages else model.messages } |> handle_exam_message kind arg, Cmd.none)
|
||||
Err s -> ({ model | messages = (Debug.toString s)::model.messages }, Cmd.none)
|
||||
|
||||
SendPartMessage question part msgtype extras ->
|
||||
(model, sendMessage (encode_part_message question part msgtype extras))
|
||||
|
||||
encode_message msgtype extras =
|
||||
JE.object
|
||||
([("msgtype", JE.string msgtype)]++extras)
|
||||
|
||||
encode_question_message question msgtype extras =
|
||||
encode_message "question" [ ("msg", JE.object ([("msgtype", JE.string msgtype)]++extras)), ("questionNumber", JE.int question.number) ]
|
||||
|
||||
encode_part_message question part msgtype extras =
|
||||
encode_question_message question "part" [ ("msg", JE.object ([("msgtype", JE.string msgtype)]++extras)), ("partPath", JE.string part.path)]
|
||||
|
||||
{- update the ith item in a list using the given map function -}
|
||||
update_list : Int -> (a -> a) -> List a -> List a
|
||||
update_list i fn list = list |> List.indexedMap pair |> List.map (\(j,a) -> if i==j then fn a else a)
|
||||
|
||||
map_exam : (Exam -> Exam) -> Model -> Model
|
||||
map_exam fn model = { model | exam = Maybe.map fn model.exam }
|
||||
|
||||
map_question : Int -> (Question -> Question) -> Model -> Model
|
||||
map_question i fn model = model |> map_exam (\exam -> { exam | questions = update_list i fn exam.questions })
|
||||
|
||||
map_part : String -> (Part -> Part) -> Question -> Question
|
||||
map_part path fn question = { question | parts = List.map (\p -> if p.path==path then fn p else p) question.parts }
|
||||
|
||||
show_question : Model -> Model
|
||||
show_question model =
|
||||
case model.exam of
|
||||
Nothing -> model
|
||||
Just exam -> case JD.decodeValue (JD.field "currentQuestionNumber" JD.int) exam.object of
|
||||
Ok n ->
|
||||
let
|
||||
nexam = { exam | currentQuestion = n }
|
||||
in
|
||||
{model | exam = Just nexam}
|
||||
Err _ -> model
|
||||
|
||||
update_question_list = map_exam (\e -> {e | questions = get_questions e.object })
|
||||
|
||||
type alias MessageHandler m = String -> Value -> m -> m
|
||||
|
||||
handle_message : Dict String (Value -> m -> m) -> MessageHandler m
|
||||
handle_message message_handlers kind value model =
|
||||
let
|
||||
handler = Dict.get kind message_handlers |> Maybe.withDefault (\_ -> identity)
|
||||
in
|
||||
handler value model
|
||||
|
||||
handle_exam_message : MessageHandler Model
|
||||
handle_exam_message = handle_message <| Dict.fromList
|
||||
[ ("showQuestion", \_ -> show_question)
|
||||
, ("updateQuestionList", \_ -> update_question_list)
|
||||
, ("question", \value model ->
|
||||
let
|
||||
mexam : Maybe Exam
|
||||
mexam = model.exam
|
||||
|
||||
q = Debug.log "question message" minfo
|
||||
|
||||
minfo = value |> JD.decodeValue
|
||||
(JD.map3 QuestionMessage
|
||||
(JD.field "questionNumber" JD.int)
|
||||
(JD.field "type" JD.string)
|
||||
(JD.field "arg" JD.value)
|
||||
)
|
||||
|> Result.toMaybe
|
||||
|
||||
mquestion : Maybe Question
|
||||
mquestion = Maybe.map2 get_question mexam (Maybe.map .questionNumber minfo) |> Maybe.andThen identity
|
||||
in
|
||||
case (minfo, mexam) of
|
||||
(Just info, Just exam) -> map_question info.questionNumber (handle_question_message info.type_ info.msg) model
|
||||
_ -> model
|
||||
)
|
||||
]
|
||||
|
||||
handle_question_message : MessageHandler Question
|
||||
handle_question_message = handle_message <| Dict.fromList
|
||||
[ ("isDirty", \v q ->
|
||||
v |> JD.decodeValue (JD.bool) |> Result.map (\dirty -> {q | isDirty = dirty}) |> Result.withDefault q
|
||||
)
|
||||
, ("showScore", \_ q -> {q | score = JD.decodeValue (JD.field "score" JD.float) q.object |> Result.withDefault q.score })
|
||||
, ("part", \value question ->
|
||||
let
|
||||
minfo = value |> JD.decodeValue
|
||||
(JD.map3 PartMessage
|
||||
(JD.field "partPath" JD.string)
|
||||
(JD.field "type" JD.string)
|
||||
(JD.field "arg" JD.value)
|
||||
)
|
||||
|> Result.toMaybe
|
||||
|
||||
q = Debug.log "part message" minfo
|
||||
|
||||
mpart = Maybe.map (.partPath >> get_part question) minfo
|
||||
in
|
||||
case (minfo, mpart) of
|
||||
(Just info, Just part) -> map_part info.partPath (handle_part_message info.type_ info.msg) question
|
||||
_ -> question
|
||||
)
|
||||
]
|
||||
|
||||
handle_part_message : MessageHandler Part
|
||||
handle_part_message = handle_message <| Dict.fromList
|
||||
[ ("showScore", \_ p ->
|
||||
let
|
||||
score = decode_default (JD.field "score" JD.float) 0 p.object
|
||||
marks = decode_default (JD.field "marks" JD.float) 0 p.object
|
||||
marking_feedback = decode_default (JD.field "markingFeedback" (JD.list decode_feedback_message)) [] p.object
|
||||
in
|
||||
{p | score = score, marks = marks, marking_feedback = marking_feedback })
|
||||
]
|
||||
|
||||
subscriptions model =
|
||||
Sub.batch
|
||||
[ receiveMessage ReceiveMessage
|
||||
]
|
||||
|
||||
get_exam_name : Exam -> String
|
||||
get_exam_name = .object >> JD.decodeValue (JD.at ["settings","name"] JD.string) >> Result.withDefault "Unnamed exam"
|
||||
|
||||
get_questions : JE.Value -> List Question
|
||||
get_questions = JD.decodeValue (JD.at ["questionList"] (JD.list decode_question)) >> Result.withDefault []
|
||||
|
||||
get_question : Exam -> Int -> Maybe Question
|
||||
get_question exam n = listGet n exam.questions
|
||||
|
||||
get_part : Question -> String -> Maybe Part
|
||||
get_part question path = question.parts |> List.filter (\p -> p.path == path) |> List.head
|
||||
|
||||
decode_question : JD.Decoder Question
|
||||
decode_question =
|
||||
JD.succeed Question
|
||||
|> JDP.hardcoded False
|
||||
|> JDP.hardcoded 0
|
||||
|> JDP.custom JD.value
|
||||
|> required "name" JD.string
|
||||
|> requiredAt ["display", "allParts"] (JD.list decode_part)
|
||||
|> required "number" JD.int
|
||||
|> JDP.optionalAt ["display", "statement"] JD.value (JE.string "")
|
||||
|
||||
decode_part : JD.Decoder Part
|
||||
decode_part =
|
||||
JD.succeed Part
|
||||
|> JDP.custom JD.value
|
||||
|> required "name" JD.string
|
||||
|> required "type" decode_part_type
|
||||
|> required "path" JD.string
|
||||
|> required "score" JD.float
|
||||
|> required "marks" JD.float
|
||||
|> JDP.optionalAt ["display", "prompt"] JD.value (JE.string "")
|
||||
|> JDP.optional "markingFeedback" (JD.list decode_feedback_message) []
|
||||
|> JDP.optional "gaps" (JD.map Gaps <| JD.list (JD.lazy (\_ -> decode_part))) (Gaps [])
|
||||
|
||||
part_types : Dict String PartType
|
||||
part_types = Dict.fromList
|
||||
[ ("information", InformationOnlyPart)
|
||||
, ("numberentry", NumberEntryPart)
|
||||
, ("jme", MathematicalExpressionPart)
|
||||
, ("patternmatch", PatternMatchPart)
|
||||
]
|
||||
|
||||
decode_part_type : JD.Decoder PartType
|
||||
decode_part_type =
|
||||
JD.string |> JD.map (\t -> Dict.get t part_types |> Maybe.withDefault (CustomPart t))
|
||||
|
||||
decode_feedback_message =
|
||||
JD.succeed FeedbackMessage
|
||||
|> required "op" JD.string
|
||||
|> JDP.optional "credit" (JD.maybe JD.float) Nothing
|
||||
|> required "message" JD.value
|
||||
|> JDP.optional "reason" (JD.maybe JD.string) Nothing
|
||||
|> JDP.optional "credit_change" (JD.maybe JD.string) Nothing
|
||||
|
||||
external_html : JE.Value -> Html Msg
|
||||
external_html html =
|
||||
H.node "elm-html"
|
||||
[ HA.property "html" html ]
|
||||
[]
|
||||
|
||||
format_score : Float -> Float -> String
|
||||
format_score score marks = (String.fromFloat score)++"/"++(String.fromFloat marks)
|
||||
|
||||
view model = case model.exam of
|
||||
Nothing -> view_loading
|
||||
Just exam ->
|
||||
case model.view_mode of
|
||||
ViewWholeExam -> view_exam model exam
|
||||
|
||||
ViewQuestion n -> case get_question exam n of
|
||||
Just question -> view_question question
|
||||
Nothing -> H.text "That question doesn't exist"
|
||||
|
||||
ViewPart n path -> case get_question exam n |> Maybe.andThen (\q -> get_part q path |> Maybe.map (pair q)) of
|
||||
Just (question, part) -> view_part question part
|
||||
Nothing -> H.text "That part doesn't exist"
|
||||
|
||||
ViewPartAnswer n path -> case get_question exam n |> Maybe.andThen (\q -> get_part q path |> Maybe.map (pair q)) of
|
||||
Just (question, part) -> view_part_answer_input question part
|
||||
Nothing -> H.text <| "That part (q"++(String.fromInt n)++(path)++") doesn't exist"
|
||||
|
||||
view_loading = H.text "It's loading"
|
||||
|
||||
view_exam : Model -> Exam -> Html Msg
|
||||
view_exam model exam =
|
||||
let
|
||||
exam_name = get_exam_name exam
|
||||
questions = exam.questions
|
||||
|
||||
in
|
||||
H.div
|
||||
[]
|
||||
[ H.h1 [] [H.text <| exam_name]
|
||||
{-
|
||||
, H.ul
|
||||
[]
|
||||
(List.map (\msg -> H.li [] [H.text msg]) (List.take 5 model.messages))
|
||||
-}
|
||||
, H.ul
|
||||
[]
|
||||
(List.map (\q -> H.li [] [view_question q]) questions)
|
||||
]
|
||||
|
||||
view_question question =
|
||||
H.div
|
||||
[]
|
||||
[ H.h2 [] [H.text question.name, H.text <| if question.isDirty then " (dirty)" else " (clean)"]
|
||||
, H.p [] [H.text "Score: ", H.text <| String.fromFloat question.score]
|
||||
, external_html question.statement
|
||||
, H.ul
|
||||
[]
|
||||
(List.map (\p -> H.li [] [view_part question p]) question.parts)
|
||||
]
|
||||
|
||||
view_part : Question -> Part -> Html Msg
|
||||
view_part question part =
|
||||
let
|
||||
gaps = case part.gaps of
|
||||
Gaps g -> g
|
||||
in
|
||||
H.div
|
||||
[]
|
||||
[ H.h3 [] [H.text part.name]
|
||||
, H.p [] [H.text <| Debug.toString part.type_]
|
||||
, H.p [] [H.text <| "Score: " ++ (format_score part.score part.marks)]
|
||||
, external_html part.prompt
|
||||
, view_part_answer_input question part
|
||||
, H.ul
|
||||
[]
|
||||
(List.map (\f -> view_feedback_message f) part.marking_feedback)
|
||||
]
|
||||
|
||||
view_part_answer_input question part =
|
||||
let
|
||||
msgs =
|
||||
{ store_answer =
|
||||
pair "answer"
|
||||
>> List.singleton
|
||||
>> SendPartMessage question part "part_answer"
|
||||
|
||||
, submit_answer = SendPartMessage question part "submit" []
|
||||
}
|
||||
in
|
||||
case part.type_ of
|
||||
GapFillPart -> H.text ""
|
||||
InformationOnlyPart -> H.text ""
|
||||
_ ->
|
||||
H.form
|
||||
[ HE.onSubmit msgs.submit_answer
|
||||
, HA.attribute "part" "answer-input-form"
|
||||
, HE.on "focusout" (JD.succeed msgs.submit_answer)
|
||||
]
|
||||
[ case part.type_ of
|
||||
NumberEntryPart -> number_entry_input msgs
|
||||
MathematicalExpressionPart -> mathematical_expression_input msgs
|
||||
_ -> H.text ""
|
||||
]
|
||||
|
||||
type alias AnswerMessages =
|
||||
{ store_answer : JE.Value -> Msg
|
||||
, submit_answer : Msg
|
||||
}
|
||||
|
||||
type alias AnswerInput = AnswerMessages -> Html Msg
|
||||
|
||||
number_entry_input : AnswerInput
|
||||
number_entry_input msgs =
|
||||
H.input
|
||||
[ HE.onInput (
|
||||
JE.string
|
||||
>> msgs.store_answer
|
||||
)
|
||||
]
|
||||
[]
|
||||
|
||||
mathematical_expression_input : AnswerInput
|
||||
mathematical_expression_input msgs =
|
||||
H.input
|
||||
[ HE.onInput (
|
||||
JE.string
|
||||
>> msgs.store_answer
|
||||
)
|
||||
]
|
||||
[]
|
||||
|
||||
view_feedback_message f =
|
||||
let
|
||||
feedback_icon = case f.credit_change of
|
||||
Just "positive" -> H.text "+"
|
||||
Just "negative" -> H.text "-"
|
||||
_ -> H.text ""
|
||||
in
|
||||
H.li
|
||||
[HA.class "feedback-message"]
|
||||
[ feedback_icon
|
||||
, external_html f.message
|
||||
]
|
3
style.css
Normal file
3
style.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
section {
|
||||
margin-bottom: 20vh;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue