feat: Improve construction element handling (#32)

This change implements several additional construction methods,
  including the first polygon ones. In particular, it now allows
  arbitrary strings as entity names, even ones that are not allowed
  as GeoGebra identifiers, using captions to show the original
  entity names. In addition, line arguments are interpreted as a pair
  of point arguments as needed.

  Resolves #6.
  Resolves #30.
  Resolves #31.

Reviewed-on: #32
Co-authored-by: Glen Whitney <glen@studioinfinity.org>
Co-committed-by: Glen Whitney <glen@studioinfinity.org>
This commit is contained in:
Glen Whitney 2023-09-25 00:47:35 +00:00 committed by Glen Whitney
parent 550ce0168c
commit bab48b25ad
5 changed files with 448 additions and 63 deletions

View file

@ -16,6 +16,30 @@ $('applet[code="Geometry"]').before (i, html) ->
height: parseInt(this.getAttribute('height') ?? '200') }
`<div id="${id}"></div>`
type Split<S extends string>
S extends `${infer W} ${infer R}` ? (W | Split<R>) : S
classes := 'point line circle polygon sector plane sphere polyhedron'
type JoyceClass = Split<typeof classes>
function assertJoyceClass(s: string): asserts s is JoyceClass
unless classes.includes s then throw new Error `Oops ${s} slipped through`
type JoyceName = string // we use this to indicate where the names
// from the Joyce commands (which are used as captions in the GeoGebra
// applet) go.
type GeoName = string // and this to indicate where GeoGebra identifiers go
type AnyName = GeoName | JoyceName // and this for slots that can be either
type Description
otherName: AnyName
usesCaptions: JoyceName[]
klass: JoyceClass
ends?: [GeoName, GeoName]
// We put both JoyceNames and GeoNames in here, pointing to each other
// with the otherName property:
type JoyceElements = Record<AnyName, Description>
jQuery.getScript 'https://www.geogebra.org/apps/deployggb.js', =>
for each jApp of joyceApplets
params := {
@ -23,100 +47,325 @@ jQuery.getScript 'https://www.geogebra.org/apps/deployggb.js', =>
jApp.width,
jApp.height,
appletOnLoad: (api: AppletObject) =>
elements: JoyceElements := {}
for child of jApp.children
dispatchJcommand api, child
dispatchJcommand api, child, elements
api.setCoordSystem(-10, 10 + jApp.width, -10, 10 + jApp.height)
} as const
geoApp := new GGBApplet params
geoApp.inject jApp.id
type GeogebraCallback = (api: AppletObject) => void
type DimParts = [string[], string[], string[]] // Gives GeoNames
// or expressions for 0-, 1-, and 2-dimensional parts for coloring
// need to pass the parts into the callbacks because sometimes the parts
// are not generated until callback time
type GeogebraCallback = (api: AppletObject, parts: DimParts) => void
type Commander
command: string
commands: string[]
callbacks: GeogebraCallback[]
parts: DimParts
auxiliaries: GeoName[] // extra entities needed in GeoGebra
ends?: [GeoName, GeoName]
function freshCommander(): Commander
commands: []
callbacks: []
parts: [[], [], []]
auxiliaries: []
type JoyceArguments =
Partial<Record<JoyceClass|'subpoints', GeoName[]> & {scalar: number[]}>
type ClassHandler = (
name: string, m: string, data: string, colors: string[]) => Commander
name: GeoName, m: string, args: JoyceArguments, index: number) => Commander
function dispatchJcommand(api: AppletObject, param: Element): void
// Executes the command corresponding to param against the GeoGebra applet
// api, consulting and extending by side effect the elements that are
// present in that applet
function dispatchJcommand(
api: AppletObject, param: Element, elements: JoyceElements): void
val := param.getAttribute 'value'
unless val return
switch param.getAttribute 'name'
attr := param.getAttribute 'name'
switch attr
'background'
api.setGraphicsOptions 1, bgColor: `#${val}`
'title'
api.evalCommand `TitlePoint = Corner(1,1)
Text("${val}", TitlePoint + (2,5))`
/e\[\d+\]/
{command, callbacks} := jToG val
if command
if api.evalCommand command
for each cb of callbacks
cb(api)
else
console.log `Geogebra command '${command}' translated from`,
val, 'failed.'
else
console.log `Could not parse command '${val}'`
else
console.log `Unkown param ${param}`
num := parseInt(attr.slice(2))
{commands, callbacks, parts} := jToG val, elements, num
if commands.length
lastTried .= 0
if commands.filter((&)).every (cmd) =>
api.evalCommand(cmd) and ++lastTried
callbacks.forEach &(api, parts)
else console.log
`Geogebra command '${commands[lastTried]}'
(part of translation of '${val}')
failed.`
else console.log `Could not parse command '${val}'`
else console.log `Unkown param ${param}`
function jToG(jCom: string): Commander
[name, klass, method, data, ...colors] := jCom.split(';')
if klass in classHandler
return classHandler[klass] name, method, data, colors
console.log `Unknown entity class ${klass}`
command: '', callbacks: []
// function myListener(...args: unknown[]) {
// console.log 'In my listener with', args
// }
classHandler: Record<string, ClassHandler> :=
point: (name, method, data, colors) =>
command .= ''
callbacks: GeogebraCallback[] .= []
args := data.split(',')
// window.myListener = myListener
// Parses a Joyce element-creating command, extending the elements
// by side effect:
function jToG(jCom: string, elements: JoyceElements, index: number): Commander
[jname, klass, method, data, ...colors] := jCom.split(';')
cmdr .= freshCommander()
unless klass in classHandler
console.log `Unknown entity class ${klass}`
return cmdr
assertJoyceClass klass // shouldn't need to do that :-/
name := if /^\p{L}\w*$/u.test jname then jname else geoname jname, elements
args: JoyceArguments := {}
usesCaptions := []
for each jdep of data.split ','
scalar := parseFloat jdep
if scalar is scalar // not NaN
(args.scalar ?= []).push scalar
continue
unless jdep in elements
console.log `Reference to unknown geometric entity ${jdep} in $jCom}`
return cmdr
usesCaptions.push jdep
{klass: depKlass, otherName: depGeo, ends} := elements[jdep]
(args[depKlass] ?= []).push depGeo
if depKlass is 'point'
(args.subpoints ?= []).push depGeo
else if depKlass is 'line'
(args.subpoints ?= []).push ...ends ?? []
cmdr = classHandler[klass] name, method, args, index
unless name is jname then cmdr.callbacks.push (api: AppletObject) =>
api.setCaption(name, jname)
api.setLabelStyle(name, 3) // style CAPTION = 3
if cmdr.auxiliaries.length
cmdr.callbacks.push (api: AppletObject) =>
for each aux of cmdr.auxiliaries
api.setAuxiliary(aux, true)
api.setVisible(aux,false)
// Create callback to assign colors
if colors.length is 4 and colors.every (color) =>
false and color is '0' or color is 'none'
cmdr.callbacks.push (api: AppletObject) =>
api.setVisible(name, false)
// window[hideListener] = (arg) =>
// console.log('Hello', arg, 'disappearing', name)
// api.setVisible(name, false)
api.registerObjectUpdateListener(name, hideListener)
if cmdr.ends or klass is 'line'
elements[jname] =
{otherName: name, usesCaptions, klass: 'line', cmdr.ends}
elements[name] =
{otherName: jname, usesCaptions, klass: 'line', cmdr.ends}
else // any other geometry
elements[jname] = {otherName: name, usesCaptions, klass}
elements[name] = {otherName: jname, usesCaptions, klass}
cmdr
function geoname(jname: JoyceName, elements: JoyceElements): GeoName
numCode .= 0n
numCode = numCode*128n + BigInt ch.codePointAt(0) ?? 1 for each ch of jname
return .= 'Geo' + numCode.toString(36);
return += '1' while return.value in elements
// All of the detailed semantics of each available command lies in this
// function.
classHandler: Record<JoyceClass, ClassHandler> :=
point: (name, method, args): Commander =>
return := freshCommander()
{commands, callbacks, parts, auxiliaries} := return.value
aux := name + 'aUx'
parts[0].push name
switch method
/free|fixed/
command += `${name} = (${data})`
commands.push `${name} = (${args.scalar?.join ','})`
if method is 'fixed'
callbacks.push (api: AppletObject) => api.setFixed(name, true)
'perpendicular'
[center, direction] := args
command += `${name} = Rotate(${direction}, 3*pi/2, ${center})`
// Note only the two-point option implemented so far
unless args.subpoints return
[center, direction] := args.subpoints
// Note clockwise 90° rotation (3π/2) confirmed in Joyce source
commands.push `${name} = Rotate(${direction}, 3*pi/2, ${center})`
'angleDivider'
n .= -1
// use the fact that NaN doesn't equal itself:
nLoc := args.findIndex((arg) => (n = parseInt arg) is n)
if n >= 0
args.splice(nLoc)
[center, start, end] := args
command += `${name}aUx1 = Segment(${start}, ${end})
${name}aUx2 = Angle(${start}, ${center}, ${end})
${name}aUx2a = If(${name}aUx2 > pi, ${name}aUx2 - 2*pi, ${name}aUx2)
${name}aUx3 = Rotate(${start}, ${name}aUx2a/${n}, ${center})
${name}aUx4 = Ray(${center}, ${name}aUx3)
${name} = Intersect(${name}aUx1, ${name}aUx4)`
// Note doesn't yet handle plane argument
unless args.subpoints return
[start, center, end] := args.subpoints
// see if we need to make the destination segment from start to end
destination .= ''
unless args.line?.length is 1 and args.point?[0] is center
destination = aux + '1'
auxiliaries.push destination
commands.push `${destination} = Segment(${start}, ${end})`
else destination = args.line[0]
n := args.scalar?[0]
commands.push
`${aux}2 = Angle(${start}, ${center}, ${end})`
`${aux}3 = If(${aux}2 > pi, ${aux}2 - 2*pi, ${aux}2)`
`${aux}4 = Rotate(${start}, ${aux}3/${n}, ${center})`
`${name} = Intersect(${destination}, Ray(${center}, ${aux}4))`
auxiliaries.push ...[2..4].map (i) => `${aux}${i}`
'intersection'
command += `${name} = Intersect(${data})`
return {command, callbacks}
// Checking Joyce source, means intersection of lines, not
// intersection of line segments
unless args.subpoints then return
l1 := `Line(${args.subpoints[0]},${args.subpoints[1]})`
l2 := `Line(${args.subpoints[2]},${args.subpoints[3]})`
commands.push `${name} = Intersect(${l1},${l2})`
'lineSegmentSlider'
segment .= args.line?[0]
unless segment
unless args.point then return
commands.push `${aux} = Segment(${args.point.join ','})`
auxiliaries.push aux
segment = aux
commands.push `${name} = Point(${segment})`
if args.scalar and args.scalar.length
callbacks.push (api: AppletObject) =>
api.setCoords name,
...args.scalar as [number, number, number]
'first'
unless args.subpoints then return
commands.push `${name} = ${args.subpoints[0]}`
'last'
unless args.subpoints then return
commands.push `${name} = ${args.subpoints.at(-1)}`
'extend'
unless args.subpoints then return
sp := args.subpoints
direction .= `UnitVector(Vector(${sp[0]},${sp[1]}))`
if args.line and (
not args.point or args.point[0] !== args.subpoints[0])
direction = `UnitVector(${args.line[0]})`
displacement := `Distance(${sp[2]}, ${sp[3]})*${direction}`
commands.push `${name} = Translate(${sp[1]}, ${displacement})`
'vertex'
commands.push
`${name} = Vertex(${args.polygon?[0]},${args.scalar?[0]})`
'midpoint'
if args.line
commands.push `${name} = Midpoint(${args.line[0]})`
else
commands.push
`${name} = Midpoint(${args.point?[0]},${args.point?[1]})`
'foot'
pt := args.subpoints
unless pt then return
commands.push
`${name} = ClosestPoint(Line(${pt[1]},${pt[2]}), ${pt[0]})`
line: (name, method, data, colors) =>
command .= ''
callbacks: GeogebraCallback[] .= []
args := data.split(',')
line: (name, method, args) =>
return := freshCommander()
return.value.ends = ['', '']
{commands, callbacks, parts, auxiliaries, ends} := return.value
aux := name + 'aUx'
parts[1].push name
madeSegment .= false
switch method
'connect'
command += `${name} = Segment(${data})`
unless args.subpoints and args.subpoints.length is 2 then return
ends[0] = args.subpoints[0]
ends[1] = args.subpoints[1]
'parallel'
[newStart, oldStart, oldEnd] := args
command += `${name}aUx1 = Vector(${oldStart}, ${newStart})
${name}aUx2 = Translate(${oldEnd}, ${name}aUx1)
${name} = Segment(${newStart}, ${name}aUx2)`
return {command, callbacks}
unless args.subpoints then return
[newStart, oldStart, oldEnd] := args.subpoints
commands.push `${aux}1 = Vector(${oldStart}, ${newStart})`
auxiliaries.push aux + 1, aux + 2
ends[0] = newStart
ends[1] = aux + 2
if args.line?.length is 1 and args.point?[0] is args.subpoints[0]
// In this case we are translating an existing segment
commands.push
`${name} = Translate(${args.line[0]}, ${aux}1)`
`${aux}2 = Vertex(${name}, 2)`
madeSegment = true
else
commands.push `${aux}2 = Translate(${oldEnd}, ${aux}1)`
'chord'
// To match Joyce, we need to get the ordering here correct.
// ends[0] should be the one closer to args.subpoints[0]
unless args.subpoints and args.circle then return
line := `Line(${args.subpoints.join ','})`
pt := args.subpoints[0]
commands.push ...[1..2].map (n) =>
`${aux}${n} = Intersect(${args.circle}, ${line}, ${n})`
condition := `Distance(${aux}2,${pt}) < Distance(${aux}1,${pt})`
// NOTE: Joyce's code has special case for when pt is almost
// at midpoint of chord; in that case, it starts at endpoint
// closer to the second subpoint... postponing that nicety
commands.push
`${aux}3 = If(${condition}, ${aux}2, ${aux}1)`
`${aux}4 = If(${condition}, ${aux}1, ${aux}2)`
ends[0] = aux + 3
ends[1] = aux + 4
auxiliaries.push ...[1..4].map (n) => aux + n
unless madeSegment
commands.push `${name} = Segment(${ends[0]},${ends[1]})`
callbacks.push (api: AppletObject) =>
api.setLabelVisible(name, true)
parts[0].push ...ends
circle: (name, method, data, colors) =>
command .= ''
callbacks: GeogebraCallback[] .= []
circle: (name, method, args) =>
return := freshCommander()
{commands, callbacks, parts, auxiliaries} := return.value
parts[2].push name
parts[1].push name
switch method
'radius'
[center, point] := data.split(',')
command += `${name} = Circle(${center}, ${point})`
return {command, callbacks}
unless args.subpoints then return
[center, point] := args.subpoints
commands.push `${name} = Circle(${center}, ${point})`
callbacks.push (api: AppletObject) => api.setLabelVisible(name, true)
polygon: (name, method, args, index) =>
return := freshCommander()
{commands, callbacks, parts, auxiliaries} := return.value
parts[2].push name
// what to push for edges?
switch method
'equilateralTriangle'
pt := args.subpoints
unless pt then return
commands.push '' // hack, make sure there is a command
parts[0].push pt[0], pt[1]
callbacks.push (api: AppletObject, moreParts: DimParts) =>
made:= api.evalCommandGetLabels
`${name} = Polygon(${pt[1]},${pt[0]}, 3)`
if not made return
for each obj of made.split ','
if obj is name continue
newObj := 'GeoAux' + index + obj
api.renameObject obj, newObj
switch api.getObjectType newObj
'segment'
parts[1].push newObj
'point'
parts[0].push newObj
api.setVisible(newObj, false)
/triangle|quadrilateral/
pt := args.subpoints
unless pt then return
commands.push ''
parts[0].push ...pt
callbacks.push (api: AppletObject, moreParts: DimParts) =>
made := api.evalCommandGetLabels
`${name} = Polygon(${pt.join ','})`
if not made return
for each obj of made.split ','
if obj is name continue
newObj := 'GeoAux' + index + obj
api.renameObject obj, newObj
parts[1].push newObj
sector: (name, method, args) => freshCommander()
plane: (name, method, args) => freshCommander()
sphere: (name, method, args) => freshCommander()
polyhedron: (name, method, args) => freshCommander()

View file

@ -47,7 +47,7 @@ function makeBrowser(url: string)
canvas
// Put eye icons after all of the eligible links
links := $('a').filter -> !!@.getAttribute('href')?.match knownExtensions
links := $('a').filter -> knownExtensions.test @.getAttribute('href') ?? ''
links.after ->
newSpan := $('<span>👁</span>')
newSpan.hover