first commit

This commit is contained in:
Christian Lawson-Perfect 2025-02-09 20:33:33 +00:00
commit 9e4d936a96
9 changed files with 4864 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
jme-runtime.js
locales.js
code-editor.mjs

11
animate.js Normal file
View file

@ -0,0 +1,11 @@
const drawing = editor.drawing;
const duration = 10;
const fps = 5;
const frames = [];
for(let frame = 0; frame < duration*fps; frame++) {
const t = frame/fps;
drawing.ctx.get_time = () => t;
drawing.ctx.draw();
frames.push(drawing.html);
}
console.log(frames.join('\n\n<<FRAME SEPARATOR>>\n\n'))

35
drawing.txt Normal file
View file

@ -0,0 +1,35 @@
let(
margin, 0.1,
eukleides("",-0.1-margin,-margin,sqrt(2)+0.1+margin,1+margin,
let(
ease, x -> if(x<0.5, 4x^3, 1 - ((2-2x)^3)/2),
frame, (from,to) -> ease(clamp((time-from)/(to-from), 0, 1)),
frame_lengths, [3,1,1,5],
frame_times, foldl((a,b) -> a+[a[-1]+b], [0], frame_lengths),
frame_number, (take(1, i -> frame_times[i]>=time, 0..len(frame_times)-1)+[len(frame_times)])[0]-1,
frames, frame(x,y) for: [x,y] of: zip(frame_times, frame_times[2..len(frame_times)]),
[a,b,c,d], rectangle(sqrt(2),1),
e, point(1,0),
f, point(1,1),
square_label, center(a..e..f..d) text("210×210") size(0.5),
strip_label, center(e..b..c..f) text("87×210") size(0.5),
[
switch(
frame_number<3,
[a..b..c..d
, (e..f) opacity(frames[0])
, [square_label, strip_label
] * opacity(frames[1])
]
, [ [(a..e..f..d), square_label] - vector(frames[3]*0.1, 0)
, (x + vector(frames[3]*0.1, 0)) for: x of: [(e..b..c..f), strip_label]
]
)
//, point(0.7,0.7) text(jme_string(frame_times)) size(0.4)
//, point(0.7,0.5) text(jme_string(frames)) size(0.4)
//, point(0.5,0.3) text(jme_string(frame_number)) size(0.5)
//, point(0.5,0.1) text(jme_string(time)) size(0.5)
]
)
)
)

2833
eukleides.mjs Normal file

File diff suppressed because it is too large Load diff

1681
extension.js Normal file

File diff suppressed because it is too large Load diff

46
index.html Normal file
View file

@ -0,0 +1,46 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>Eukleides drawing</title>
<link rel="stylesheet" href="style.css">
<script type="module" src="script.js"></script>
</head>
<body>
<header>
<h1>Eukleides drawing</h1>
</header>
<main>
<div id="left-side">
<details id="files">
<summary>Editing <code id="current-file"></code></summary>
<ul>
</ul>
<form id="save-as-form">
<label for="save-as">Save as</label>
<input id="save-as" type="text">
<button>Save</button>
</form>
</details>
<code-editor id="code"></code-editor>
</div>
<div id="right-side">
<p><a id="download-still" href="#" download>Download still</a></p>
<p>
<label for="fps">FPS:</label>
<input type="number" min="1" value="10" id="fps">
<label for="width">Width:</label>
<input type="number" min="1" value="600" id="width">
</p>
<p><button type="button" id="make-animation">Make animation</button> <a id="download-animation" download>Download animation</a></p>
<p>
<input type="range" min="0" max="10" value="0" step="0.01" id="time">
<output for="time">0</output>
</p>
<div id="display"></div>
</div>
</div>
</main>
</body>
</html>

30
make_animation.py Normal file
View file

@ -0,0 +1,30 @@
import base64
from pathlib import Path
import json
import subprocess
import sys
fps = 5
infile = Path(sys.argv[1])
with open(infile) as f:
data = json.load(f)
fps = data['fps']
duration = data['duration']
frames = data['frames']
print(len(frames))
for i,frame in enumerate(frames):
print(i)
pngdir = Path('pngs')
pngdir.mkdir(exist_ok=True)
filename = Path(f'frame-{i:04d}.png')
pngname = pngdir / filename
with open(pngname, 'wb') as f:
png = base64.b64decode(frame[len('data:image/png;base64,'):])
f.write(png)
outfile = Path(infile.name).with_suffix('.mp4')
subprocess.run(['ffmpeg','-r',str(fps),'-f','image2','-i','pngs/frame-%04d.png','-vcodec','libx264','-crf','25','-pix_fmt','yuv420p','-vf','pad=ceil(iw/2)*2:ceil(ih/2)*2',outfile,'-y']);

195
script.js Normal file
View file

@ -0,0 +1,195 @@
import './jme-runtime.js';
import './locales.js';
import * as euk from './eukleides.mjs';
import './extension.js';
import './code-editor.mjs';
window.eukleides = euk;
function toBinary(string) {
const codeUnits = new Uint16Array(string.length);
for (let i = 0; i < codeUnits.length; i++) {
codeUnits[i] = string.charCodeAt(i);
}
return btoa(String.fromCharCode(...new Uint8Array(codeUnits.buffer)));
}
class Editor {
constructor() {
this.files = [];
this.scope = new Numbas.jme.Scope([Numbas.jme.builtinScope, Numbas.extensions.eukleides.scope])
this.files = JSON.parse(localStorage.getItem('files') || '{"drawing":""}');
this.code_editor = document.getElementById('code');
this.display = document.getElementById('display');
this.time_input = document.getElementById('time');
this.time_display = document.querySelector('output[for="time"]');
this.fps_input = document.getElementById('fps');
this.width_input = document.getElementById('width');
this.download_still_link = document.getElementById('download-still');
this.make_animation_button = document.getElementById('make-animation');
this.download_animation_link = document.getElementById('download-animation');
this.code_editor.addEventListener('change', e => {
const code = this.code_editor.value;
this.save_file(code);
this.render();
})
this.time_input.addEventListener('input', e => {
this.redraw();
});
this.save_as_form = document.getElementById('save-as-form');
this.save_as_form.addEventListener('submit', e => {
e.preventDefault();
this.filename = this.save_as_form.elements['save-as'].value;
this.save_file(this.code_editor.value);
this.make_files_list();
})
this.download_still_link.addEventListener('click', (e) => this.download_still());
this.make_animation_button.addEventListener('click', (e) => this.make_animation());
this.load_file('drawing');
this.render();
}
make_files_list() {
const ul = document.querySelector('#files ul');
ul.innerHTML = '';
for(let name of Object.keys(this.files)) {
const li = document.createElement('li');
const button = document.createElement('button');
li.append(button);
if(name == this.filename) {
li.ariaCurrent = 'page';
}
button.type = 'button';
button.addEventListener('click', (e) => {
this.load_file(name);
});
button.textContent = name;
ul.append(li);
}
}
load_file(name) {
this.filename = name;
this.code_editor.value = this.files[this.filename] || '';
this.make_files_list();
document.getElementById('current-file').textContent = this.filename;
}
save_file(text) {
this.files[this.filename] = text;
localStorage.setItem('files', JSON.stringify(this.files));
}
render() {
const code = this.code_editor.value;
try {
const v = this.scope.evaluate(code);
const h = Numbas.jme.castToType(v, 'html');
this.drawing = h;
h.ctx.get_time = () => this.time_input.valueAsNumber;
h.ctx.draw();
this.display.innerHTML = '';
this.display.append(...h.value);
} catch(e) {
this.drawing = null;
this.display.innerHTML = e.message;
console.clear();
console.error(e);
return;
}
}
redraw() {
if(!this.drawing) {
return;
}
this.time_display.textContent = this.time_input.value;
this.drawing.ctx.draw();
}
async to_canvas() {
console.log('to canvas');
const {min_x, min_y, max_x, max_y} = this.drawing.ctx.drawer;
const vw = max_x - min_x;
const vh = max_y - min_y;
const width = this.width_input.valueAsNumber;
const height = vh/vw * width;
const img = document.createElement('img');
document.body.appendChild(img);
const canvas = new OffscreenCanvas(width, height);
return new Promise((resolve) => {
// set it as the source of the img element
img.onload = async function() {
console.log('loaded');
// draw the image onto the canvas
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'white';
ctx.fillRect(0,0,width,height);
ctx.drawImage(img, 0, 0, width, height);
const blob = await canvas.convertToBlob();
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = function() {
const base64data = reader.result;
resolve(base64data);
img.parentElement.removeChild(img);
}
}
console.log('setting src');
img.src = this.to_blob_url();
})
}
to_blob_url() {
const content = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' + this.display.innerHTML;
const blob = new Blob([content], {type: 'image/svg+xml'});
const url = URL.createObjectURL(blob);
return url
}
async download_still() {
this.download_still_link.download = this.filename+'.svg';
this.download_still_link.href = this.to_blob_url();
}
async make_animation() {
this.download_animation_link.href = '';
const duration = 10;
const fps = this.fps_input.valueAsNumber;
const frames = [];
for(let i=0; i<fps*duration; i++) {
console.log('>> frame',i);
this.drawing.ctx.get_time = () => i/fps;
this.drawing.ctx.draw();
frames.push(await this.to_canvas())
}
const blob = new Blob([JSON.stringify({fps, duration, frames})], {type: 'application/json'});
const url = URL.createObjectURL(blob);
this.download_animation_link.download = this.filename+'.json';
this.download_animation_link.href = url;
}
}
Numbas.queueScript('base', [], function () {});
Numbas.runImmediately(['jme'], function () {});
Numbas.queueScript('base', [], function () {});
Numbas.queueScript('demo', ['extensions/eukleides/eukleides.js'], function () {
Numbas.activateExtension('eukleides');
try {
window.editor = new Editor();
} catch(e) {
console.error(e);
}
})

30
style.css Normal file
View file

@ -0,0 +1,30 @@
body {
margin: 0;
}
main {
display: grid;
grid-template:
"editor display" / 50svw 50svw;
;
width: 100svw;
& code-editor {
display: block;
overflow: auto;
}
& #time {
width: 100%;
}
& #files {
[aria-current] button {
font-weight: bold;
}
}
& #display svg {
max-height: 50svh;
}
}