This commit is contained in:
Dmitry Vasilev
2024-03-07 15:30:27 +08:00
parent 4aec05cf99
commit 0a26ac6fa5
14 changed files with 555 additions and 52 deletions

View File

@@ -0,0 +1,68 @@
// Original source: http://bricault.mit.edu/recursive-drawing
// Author: Sarah Bricault
// Canvas setup
const canvas = document.createElement('canvas')
canvas.width = 700
canvas.height = 700
document.body.appendChild(canvas)
const ctx = canvas.getContext('2d')
ctx.translate(canvas.width / 2, canvas.height)
// Draw a tree
await fractalTreeBasic({totalIterations: 10, basicLength: 10, rotate: 25})
function sleep() {
return new Promise(resolve => setTimeout(resolve, 3))
}
async function fractalTreeBasic({totalIterations, basicLength, rotate}) {
// Draw the tree trunk
const trunkLength = basicLength * 2 * Math.pow(1.2, totalIterations + 1)
const width = Math.pow(totalIterations, 0.6)
ctx.beginPath()
ctx.moveTo(0, 0)
ctx.lineTo(0, - trunkLength)
ctx.lineWidth = width
ctx.strokeStyle = 'black'
ctx.stroke()
await drawBranch(90, [0, - trunkLength], totalIterations + 1)
async function drawBranch(angle, startPoint, iterations) {
const len = basicLength * Math.pow(1.2, iterations)
const width = Math.pow(iterations, 0.6)
const red = Math.floor(255 - (iterations / totalIterations) * 255)
const green = 0
const blue = Math.floor( 255 - (iterations / totalIterations) * 255)
const color = `rgb(${red}, ${green}, ${blue})`
const x1 = startPoint[0]
const y1 = startPoint[1]
const y2 = y1 - len * Math.sin((angle * Math.PI) / 180)
const x2 = x1 + len * Math.cos((angle * Math.PI) / 180)
console.log('draw branch', x1, y1, x2, y2)
ctx.beginPath()
ctx.moveTo(x1, y1)
ctx.lineTo(x2, y2)
ctx.lineWidth = width
ctx.strokeStyle = color
ctx.stroke()
await sleep()
if (iterations - 1 > 0) {
// draw left branch
await drawBranch(angle + rotate, [x2, y2], iterations - 1)
// draw right branch
await drawBranch(angle - rotate, [x2, y2], iterations - 1)
}
}
}

View File

@@ -0,0 +1,98 @@
// Original source:
// https://www.freecodecamp.org/news/how-to-create-animated-bubbles-with-html5-canvas-and-javascript/
const canvas = document.createElement('canvas')
canvas.style.backgroundColor = '#00b4ff'
document.body.appendChild(canvas)
canvas.width = window.innerWidth
canvas.height = window.innerHeight
const context = canvas.getContext("2d")
context.font = "30px Arial"
context.textAlign = 'center'
context.fillStyle = 'white'
context.fillText('Click to spawn bubbles', canvas.width/2, canvas.height/2)
let circles = []
function draw(circle) {
context.beginPath()
context.arc(circle.x, circle.y, circle.radius, 0, 2 * Math.PI)
context.strokeStyle = `hsl(${circle.hue} 100% 50%)`
context.stroke()
const gradient = context.createRadialGradient(
circle.x,
circle.y,
1,
circle.x + 0.5,
circle.y + 0.5,
circle.radius
)
gradient.addColorStop(0.3, "rgba(255, 255, 255, 0.3)")
gradient.addColorStop(0.95, "#e7feff")
context.fillStyle = gradient
context.fill()
}
function move(circle, timeDelta) {
circle.x = circle.x + timeDelta*circle.dx
circle.y = circle.y - timeDelta*circle.dy
}
let intervalId
function startAnimation() {
if(intervalId == null) {
intervalId = setInterval(animate, 20)
}
}
function stopAnimation() {
if(intervalId != null) {
clearInterval(intervalId)
intervalId = null
}
}
let prevFrameTime
const animate = () => {
const now = Date.now()
const timeDelta = prevFrameTime == null ? 0 : now - prevFrameTime
prevFrameTime = now
if(circles.length == 0) {
return
}
context.clearRect(0, 0, canvas.width, canvas.height)
circles.forEach(circle => {
move(circle, timeDelta)
draw(circle)
})
}
const createCircles = (event) => {
startAnimation()
circles = circles.concat(Array.from({length: 50}, () => (
{
x: event.pageX,
y: event.pageY,
radius: Math.random() * 50,
dx: Math.random() * 0.3,
dy: Math.random() * 0.7,
hue: 200,
}
)))
}
canvas.addEventListener("click", createCircles)
window.onfocus = startAnimation
window.onblur = stopAnimation

View File

@@ -0,0 +1,62 @@
// Original source: http://bricault.mit.edu/recursive-drawing
// Author: Sarah Bricault
// Canvas setup
const canvas = document.createElement('canvas')
canvas.width = 700
canvas.height = 700
document.body.appendChild(canvas)
const ctx = canvas.getContext('2d')
ctx.translate(canvas.width / 2, canvas.height)
// Draw a tree
fractalTreeBasic({totalIterations: 10, basicLength: 10, rotate: 25})
function fractalTreeBasic({totalIterations, basicLength, rotate}) {
// Draw the tree trunk
const trunkLength = basicLength * 2 * Math.pow(1.2, totalIterations + 1)
const width = Math.pow(totalIterations, 0.6)
ctx.beginPath()
ctx.moveTo(0, 0)
ctx.lineTo(0, - trunkLength)
ctx.lineWidth = width
ctx.strokeStyle = 'black'
ctx.stroke()
drawBranch(90, [0, - trunkLength], totalIterations + 1)
function drawBranch(angle, startPoint, iterations) {
const len = basicLength * Math.pow(1.2, iterations)
const width = Math.pow(iterations, 0.6)
const red = Math.floor(255 - (iterations / totalIterations) * 255)
const green = 0
const blue = Math.floor( 255 - (iterations / totalIterations) * 255)
const color = `rgb(${red}, ${green}, ${blue})`
const x1 = startPoint[0]
const y1 = startPoint[1]
const y2 = y1 - len * Math.sin((angle * Math.PI) / 180)
const x2 = x1 + len * Math.cos((angle * Math.PI) / 180)
console.log('draw branch', x1, y1, x2, y2)
ctx.beginPath()
ctx.moveTo(x1, y1)
ctx.lineTo(x2, y2)
ctx.lineWidth = width
ctx.strokeStyle = color
ctx.stroke()
if (iterations - 1 > 0) {
// draw left branch
drawBranch(angle + rotate, [x2, y2], iterations - 1)
// draw right branch
drawBranch(angle - rotate, [x2, y2], iterations - 1)
}
}
}

View File

@@ -566,19 +566,26 @@ const select_and_toggle_expanded = (state, id) => {
}
}
export const expand_path = (state, node) => ({
...state,
calltree_node_is_expanded: {
...state.calltree_node_is_expanded,
...Object.fromEntries(
path_to_root(state.calltree, node)
.map(n => [n.id, true])
),
// Also expand node, since it is not included in
// path_to_root
[node.id]: true,
export const expand_path = (state, node) => {
if(state.calltree_node_is_expanded?.[node.id]) {
return state
}
})
return {
...state,
calltree_node_is_expanded: {
...state.calltree_node_is_expanded,
...Object.fromEntries(
path_to_root(state.calltree, node)
.map(n => [n.id, true])
),
// Also expand node, since it is not included in
// path_to_root
[node.id]: true,
}
}
}
export const initial_calltree_node = state => {
const root = root_calltree_node(state)

144
src/canvas.js Normal file
View File

@@ -0,0 +1,144 @@
// TODO time-travel for canvas ImageData
import {abort_replay} from './runtime/record_io.js'
import {set_record_call} from './runtime/runtime.js'
import {is_expandable} from './calltree.js'
const context_reset = globalThis?.CanvasRenderingContext2D?.prototype?.reset
function reset(context) {
if(context_reset != null) {
context_reset.call(context)
} else {
// For older browsers, `reset` may be not available
// changing width does the same as `reset`
// see https://stackoverflow.com/a/45871243/795038
context.canvas.width = context.canvas.width + 0
}
}
function canvas_reset(canvas_ops) {
for(let context of canvas_ops.contexts) {
reset(context)
}
}
export function apply_canvas_patches(window) {
const proto = window?.CanvasRenderingContext2D?.prototype
if(proto == null) {
return
}
const props = Object.getOwnPropertyDescriptors(proto)
Object.entries(props).forEach(([name, p]) => {
if(p.value != null) {
if(typeof(p.value) != 'function') {
// At the moment this was written, all canvas values were functions
return
}
const method = p.value
proto[name] = {
// declare function like this so it has `name` property set
[name]() {
const cxt = window.__cxt
set_record_call(cxt)
/*
abort replay, because otherwise animated_fractal_tree would replay
instantly (because setTimeout is in io_trace)
*/
if(!cxt.io_trace_is_recording && !cxt.is_recording_deferred_calls) {
abort_replay(cxt)
}
const version_number = ++cxt.version_counter
try {
return method.apply(this, arguments)
} finally {
cxt.canvas_ops.contexts.add(this)
cxt.canvas_ops.ops.push({
canvas_context: this,
method,
version_number,
args: arguments,
})
}
}
}[name]
}
if(p.set != null) {
const set_op = p.set
Object.defineProperty(proto, name, {
set(prop_value) {
const cxt = window.__cxt
set_record_call(cxt)
if(!cxt.io_trace_is_recording && !cxt.is_recording_deferred_calls) {
abort_replay(cxt)
}
const version_number = ++cxt.version_counter
try {
set_op.call(this, prop_value)
} finally {
cxt.canvas_ops.contexts.add(this)
cxt.canvas_ops.ops.push({
canvas_context: this,
version_number,
set_op,
prop_value,
})
}
}
})
}
})
}
function replay_op(op) {
if(op.method != null) {
op.method.apply(op.canvas_context, op.args)
} else if(op.set_op != null) {
op.set_op.call(op.canvas_context, op.prop_value)
} else {
throw new Error('illegal op')
}
}
export function redraw_canvas(state, is_replay_all_canvs_ops) {
if(state.calltree == null) {
// code is invalid or not executed yet
return
}
const cxt = state.rt_cxt
if(cxt.canvas_ops.ops == null) {
return
}
canvas_reset(cxt.canvas_ops)
if(is_replay_all_canvs_ops) {
for(let op of cxt.canvas_ops.ops) {
replay_op(op)
}
} else {
const current = state.current_calltree_node
// replay all ops up to current_calltree_node, including
const version_number = state.current_calltree_node.last_version_number
for(let op of cxt.canvas_ops.ops) {
if(op.version_number > version_number) {
break
}
replay_op(op)
}
}
}

View File

@@ -150,10 +150,13 @@ export class CallTree {
}
this.state = state
this.render_active(this.state.current_calltree_node, true)
scrollIntoViewIfNeeded(
this.container,
this.node_to_el.get(this.state.current_calltree_node.id).getElementsByClassName('call_el')[0]
)
if(prev?.current_calltree_node != state.current_calltree_node) {
// prevent scroll on adding deferred call
scrollIntoViewIfNeeded(
this.container,
this.node_to_el.get(this.state.current_calltree_node.id).getElementsByClassName('call_el')[0]
)
}
}
render_expand_node(prev_state, state) {

View File

@@ -171,9 +171,13 @@ export class Editor {
})
}
has_value_explorer() {
return this.value_explorer != null
}
unembed_value_explorer() {
if(this.value_explorer == null) {
return
if(!this.has_value_explorer()) {
return null
}
const session = this.ace_editor.getSession()

View File

@@ -6,6 +6,7 @@ import {Logs} from './logs.js'
import {IO_Trace} from './io_trace.js'
import {ShareDialog} from './share_dialog.js'
import {el} from './domutils.js'
import {redraw_canvas} from '../canvas.js'
export class UI {
constructor(container, state){
@@ -174,6 +175,27 @@ export class UI {
this.render_current_module(state.current_module)
this.set_active_tab('calltree', true)
container.addEventListener('focusin', e => {
const active = document.activeElement
let is_focus_in_editor
if(this.editor_container.contains(document.activeElement)) {
if(this.editor.has_value_explorer()) {
// depends on if we come to value explorer from editor or from debugger
is_focus_in_editor = !this.debugger_container.contains(
this.editor.value_explorer.return_to
)
} else {
is_focus_in_editor = true
}
} else {
is_focus_in_editor = false
}
if(this.prev_is_focus_in_editor != is_focus_in_editor) {
this.prev_is_focus_in_editor= is_focus_in_editor
redraw_canvas(get_state(), is_focus_in_editor)
}
})
}
set_active_tab(tab_id, skip_focus = false) {

22
src/effects.js vendored
View File

@@ -8,6 +8,7 @@ import {
} from './calltree.js'
import {current_cursor_position} from './calltree.js'
import {exec, reload_app_window, FILES_ROOT} from './index.js'
import {redraw_canvas} from './canvas.js'
// Imports in the context of `app_window`, so global variables in loaded
// modules refer to that window's context
@@ -139,7 +140,7 @@ export const render_initial_state = (ui, state, example) => {
}
}
export const apply_side_effects = (prev, next, ui) => {
export const apply_side_effects = (prev, next, ui, cmd) => {
if(prev.project_dir != next.project_dir) {
ui.files.render(next)
}
@@ -156,7 +157,6 @@ export const apply_side_effects = (prev, next, ui) => {
if(prev.entrypoint != next.entrypoint) {
localStorage.entrypoint = next.entrypoint
}
if(prev.html_file != next.html_file) {
localStorage.html_file = next.html_file
}
@@ -167,7 +167,8 @@ export const apply_side_effects = (prev, next, ui) => {
ui.editor.switch_session(next.current_module)
}
if(current_cursor_position(next) != ui.editor.get_cursor_position()) {
// Do not set cursor position on_deferred_call, because editor may be in the middle of the edition operation
if(current_cursor_position(next) != ui.editor.get_cursor_position() && cmd != 'on_deferred_call') {
ui.editor.set_cursor_position(current_cursor_position(next))
}
@@ -205,6 +206,9 @@ export const apply_side_effects = (prev, next, ui) => {
||
prev.calltree_changed_token != next.calltree_changed_token
) {
// code finished executing
const is_loading =
next.loading_external_imports_state != null
||
@@ -233,6 +237,8 @@ export const apply_side_effects = (prev, next, ui) => {
} else {
// code was already executed before current action
if(get_deferred_calls(prev) == null && get_deferred_calls(next) != null) {
ui.calltree.render_deferred_calls(next)
}
@@ -256,6 +262,16 @@ export const apply_side_effects = (prev, next, ui) => {
}
ui.logs.render_logs(next, prev.logs, next.logs)
// Redraw canvas
if(
prev.current_calltree_node != next.current_calltree_node
||
prev.calltree_node_is_expanded != next.calltree_node_is_expanded
) {
redraw_canvas(next, ui.is_focus_in_editor)
}
}
}

View File

@@ -501,6 +501,8 @@ export const eval_modules = (
window: globalThis.app_window,
storage,
canvas_ops: {ops: [], contexts: new Set()},
}
const result = run(module_fns, cxt, io_trace)

View File

@@ -31,6 +31,22 @@ export const examples = [
path: 'plot',
entrypoint: 'plot/index.js',
},
{
path: 'fractal_tree',
entrypoint: 'fractal_tree/fractal_tree.js',
with_app_window: true,
},
{
path: 'animated_fractal_tree',
entrypoint: 'animated_fractal_tree/animated_fractal_tree.js',
with_app_window: true,
},
{
path: 'canvas_animation_bubbles',
entrypoint: 'canvas_animation_bubbles/bubbles.js',
with_app_window: true,
},
].map(e => ({...e, entrypoint: e.entrypoint ?? e.path}))
const files_list = examples

View File

@@ -309,8 +309,9 @@ export const exec = (cmd, ...args) => {
// Wrap with_code_execution, because rendering values can trigger execution
// of code by toString() and toJSON() methods
with_code_execution(() => {
apply_side_effects(state, nextstate, ui)
apply_side_effects(state, nextstate, ui, cmd)
if(effects != null) {
(Array.isArray(effects) ? effects : [effects]).forEach(e => {

View File

@@ -19,33 +19,61 @@ const io_patch = (window, path, use_context = false) => {
obj[method].__original = original
}
export const abort_replay = (cxt) => {
cxt.io_trace_is_replay_aborted = true
cxt.io_trace_abort_replay()
// throw error to prevent further code execution. It
// is not necesseary, because execution would not have
// any effects anyway
const error = new Error('io replay aborted')
error.__ignore = true
throw error
}
const patched_method_prolog = (window, original, that, has_new_target, args) => {
const cxt = window.__cxt
if(cxt.io_trace_is_replay_aborted) {
// Try to finish fast
const error = new Error('io replay was aborted')
error.__ignore = true
throw error
}
// save call, so on expand_call and find_call IO functions would not be
// called.
// TODO: we have a problem when IO function is called from third-party
// lib and async context is lost
set_record_call(cxt)
if(cxt.is_recording_deferred_calls) {
// TODO record trace on deferred calls?
const value = has_new_target
? new original(...args)
: original.apply(that, args)
return {is_return: true, value}
}
return {is_return: false}
}
const make_patched_method = (window, original, name, use_context) => {
const method = function(...args) {
const cxt = window.__cxt
if(cxt.io_trace_is_replay_aborted) {
// Try to finish fast
const error = new Error('io replay was aborted')
error.__ignore = true
throw error
}
// save call, so on expand_call and find_call IO functions would not be
// called.
// TODO: we have a problem when IO function is called from third-party
// lib and async context is lost
set_record_call(cxt)
const has_new_target = new.target != null
if(cxt.is_recording_deferred_calls) {
// TODO record trace on deferred calls?
return has_new_target
? new original(...args)
: original.apply(this, args)
const {value, is_return} = patched_method_prolog(
window,
original,
this,
has_new_target,
args
)
if(is_return) {
return value
}
const cxt = window.__cxt
const cxt_copy = cxt
if(cxt.io_trace_is_recording) {
@@ -142,14 +170,7 @@ const make_patched_method = (window, original, name, use_context) => {
)
)
){
cxt.io_trace_is_replay_aborted = true
cxt.io_trace_abort_replay()
// throw error to prevent further code execution. It
// is not necesseary, becuase execution would not have
// any effects anyway
const error = new Error('io replay aborted')
error.__ignore = true
throw error
abort_replay(cxt)
} else {
const next_resolution = cxt.io_trace.find((e, i) =>
@@ -179,6 +200,7 @@ const make_patched_method = (window, original, name, use_context) => {
const next_event = cxt.io_trace[cxt.io_trace_index]
if(next_event.type == 'call') {
// TODO use abort_replay
cxt.io_trace_is_replay_aborted = true
cxt.io_trace_abort_replay()
} else {
@@ -267,6 +289,41 @@ const patch_Date = (window) => {
io_patch(window, ['Date', 'now'])
}
const patch_interval = (window, name) => {
const original = window[name]
window[name] = function(...args) {
const has_new_target = new.target != null
const {value, is_return} = patched_method_prolog(
window,
original,
this,
has_new_target,
args
)
if(is_return) {
return value
}
const cxt = window.__cxt
if(!cxt.io_trace_is_recording) {
/*
Discard io_trace. Run code without IO trace because it is not clear
how it should work with io trace
*/
abort_replay(cxt)
}
return has_new_target
? new original(...args)
: original.apply(this, args)
}
Object.defineProperty(window[name], 'name', {value: name})
}
export const apply_io_patches = (window) => {
io_patch(window, ['Math', 'random'])
@@ -276,7 +333,8 @@ export const apply_io_patches = (window) => {
// replaying from trace
io_patch(window, ['clearTimeout'])
// TODO patch setInterval to only cleanup all intervals on finish
patch_interval(window, 'setInterval')
patch_interval(window, 'clearInterval')
patch_Date(window)

View File

@@ -4,6 +4,7 @@ import {defineMultiversionArray, create_array, wrap_array} from './array.js'
import {create_object} from './object.js'
import {defineMultiversionSet} from './set.js'
import {defineMultiversionMap} from './map.js'
import {apply_canvas_patches} from '../canvas.js'
/*
Converts generator-returning function to promise-returning function. Allows to
@@ -44,6 +45,7 @@ export const run = gen_to_promise(function*(module_fns, cxt, io_trace) {
defineMultiversion(cxt.window)
apply_io_patches(cxt.window)
inject_leporello_api(cxt)
apply_canvas_patches(cxt.window)
cxt.window.__is_initialized = true
} else {
throw new Error('illegal state')