feat: handle the 'pivot' parameter of the Geometry Applet

Dealing with general click and drag on the applet and differentiating
  between a background drag and dragging an element seemed like too big
  a task, so this PR simply provides a slider to rotate the diagram when
  the pivot is defined.

  Implementing this required storing much more construction data, and
  also dealing head-on with GeoGebra's shall we say "strange" choice where
  the value of an expression depends on what name it is assigned to...
  The resolution of this last bit was to use different GeoGebra names for
  Geometry Applet points that start with something other than an uppercase
  Roman letter.
This commit is contained in:
Glen Whitney 2023-10-13 10:17:16 -07:00
parent bb1713a674
commit 85ad82d9e2
2 changed files with 155 additions and 63 deletions

View File

@ -101,7 +101,7 @@ export interface AppletObject {
getXcoord(objName: string): number;
getYcoord(objName: string): number;
getZcoord(objName: string): number;
setCoords(objName: string, x: number, y: number, z: number): void;
setCoords(objName: string, x: number, y: number, z?: number): void;
getValue(objName: string): number;
getVersion(): string;
getScreenshotBase64(callback: (data: string) => void, scale?: number): void;

View File

@ -49,6 +49,28 @@ adapParams: AdapParams :=
? {loader: 'https://www.geogebra.org/apps/deployggb.js', joyceApplets: []}
: JSON.parse(adapptScript.dataset.params ?? '') as AdapParams
type ConstructionData
id: string
bg: RGB
is3d: boolean
width: number
height: number
elements: JoyceElements
pivot: string
// Global data setup for pivoting (ugh, but necessary because the api
// is not passed to the callback, so we have to look up the slider in
// a global list):
type PivotData
api: AppletObject
pivot: string
lastAngle: number
rotatable: string[]
fixed: Record<string, boolean>
pivotData: Record<string, PivotData> := {}
function postApplets(jApplets: AppletDescription[], codebase = '')
for each jApp of jApplets
params := {
@ -58,10 +80,8 @@ function postApplets(jApplets: AppletDescription[], codebase = '')
jApp.width,
jApp.height,
appletOnLoad: (api: AppletObject) =>
elements: JoyceElements := {}
backgroundRGB := [255, 255, 255] as RGB
config3d := contains3d jApp.params
if config3d
is3d := contains3d jApp.params
if is3d
api.enable3D true
api.setPerspective 'T'
// Get rid of the xy-plane indicator
@ -72,10 +92,24 @@ function postApplets(jApplets: AppletDescription[], codebase = '')
api.setPerspective 'G'
if adapParams.config?.algebra
api.setPerspective '+A'
elements := {}
pivot .= ''
if 'pivot' in jApp.params
if is3d
console.warn('Geometry Applet "pivot" only supported for '
+ '2D constrcutions. Ignoring.')
else
pivot = geoname(jApp.id, elements, 'point') + 'Pivot'
pivotData[pivot] = {
api, jApp.params.pivot,
lastAngle: 0, rotatable: [], fixed: {}}
cdata: ConstructionData := {
bg: ([255, 255, 255] as RGB),
is3d, jApp.id, jApp.width, jApp.height, elements, pivot}
for name, value in jApp.params
dispatchJcommand
api, name, value, elements, backgroundRGB, config3d
if config3d
api, name, value, cdata
if is3d
depth .= jApp.width
if jApp.height > depth then depth = jApp.height
api.setCoordSystem
@ -135,7 +169,7 @@ type ClassHandler = (
method: string,
args: JoyceArguments,
index: number,
is3d: boolean) => Commander
cdata: ConstructionData) => Commander
type RGB = [number, number, number]
type XYZ = RGB
@ -146,28 +180,26 @@ function dispatchJcommand(
api: AppletObject,
name: string,
value: string,
elements: JoyceElements
backgroundRGB: RGB,
is3d: boolean): void
cdata: ConstructionData): void
switch name
'background'
newback := joyce2rgb value, backgroundRGB
cdata.bg = joyce2rgb value, cdata.bg
if adapParams.config?.commands
console.log 'Setting background to', value, 'interpreted as',
newback
for i of [0..2]
backgroundRGB[i] = newback[i]
api.setGraphicsOptions 1, bgColor: colorsea(backgroundRGB).hex()
cdata.bg
api.setGraphicsOptions 1, bgColor: colorsea(cdata.bg).hex()
'title'
if adapParams.config?.commands
console.log 'Setting title to', value
corner := is3d ? 'Corner(-1,1)' : 'Corner(1,1)'
corner := cdata.is3d ? 'Corner(-1,1)' : 'Corner(1,1)'
api.evalCommand `TitlePoint = ${corner}
Text("${value}", TitlePoint + (2,5))`
'pivot'
return // already handled in postApplets
/e\[\d+\]/
num := parseInt(name.slice(2))
{commands, callbacks, parts} :=
jToG value, elements, num, backgroundRGB, is3d
jToG value, num, cdata
if commands.length
lastTried .= 0
if commands.filter((&)).every (cmd) =>
@ -180,16 +212,14 @@ function dispatchJcommand(
(part of translation of '${value}')
failed.`
else console.warn `Could not parse command '${value}'`
else console.warn `Unkown param ${name} = ${value}`
else console.warn `Unknown param ${name} = ${value}`
// Parses a Joyce element-creating command, extending the elements
// by side effect:
function jToG(
jCom: string,
elements: JoyceElements,
index: number,
backgroundRGB: RGB
is3d: boolean): Commander
cdata: ConstructionData): Commander
[jname, klass, method, data, ...colors] := jCom.split ';'
if adapParams.config?.commands
console.log 'Defining', jname, 'as a', klass, 'constructed by',
@ -199,7 +229,9 @@ function jToG(
console.warn `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
isPivot := !!cdata.pivot and jname is pivotData[cdata.pivot].pivot
name := geoname jname, cdata.elements, klass
if isPivot then pivotData[cdata.pivot].pivot = name
args: JoyceArguments := {}
usesCaptions := []
for each jdep of data.split ','
@ -207,17 +239,17 @@ function jToG(
if scalar is scalar // not NaN
(args.scalar ?= []).push scalar
continue
unless jdep in elements
unless jdep in cdata.elements
console.warn `Reference to unknown geometric entity ${jdep} in $jCom}`
return cmdr
usesCaptions.push jdep
{klass: depKlass, otherName: depGeo, ends} := elements[jdep]
{klass: depKlass, otherName: depGeo, ends} := cdata.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, is3d
cmdr = classHandler[klass] name, method, args, index, cdata
unless name is jname then cmdr.callbacks.push (api: AppletObject) =>
api.setCaption name, jname
api.setLabelStyle name, 3 // style CAPTION = 3
@ -226,6 +258,25 @@ function jToG(
for each aux of cmdr.auxiliaries
api.setAuxiliary aux, true
api.setVisible aux,false
// set up the pivot if there is one
if isPivot
unless klass is 'point'
console.warn(`Can only pivot around a point, not the ${klass}`
+ `named ${jname}. Ignoring.`)
cdata.pivot = ''
cmdr.commands.push(`${cdata.pivot} = `
+ `Slider(0°,360°,1°,1,${cdata.width/3},true,true,false,false)`)
cmdr.callbacks.push (api: AppletObject) =>
api.setCaption cdata.pivot, 'Rotate Display'
api.setLabelStyle cdata.pivot, 3
api.setCoords(cdata.pivot, 2*cdata.width/3, cdata.height-10)
// Not sure how to let TypeScript deal with putting a new function
// on the global window object, so punting at least for now:
// @ts-ignore
window.pivotListener = pivotListener
api.registerObjectUpdateListener(cdata.pivot, 'pivotListener')
// Create callback to assign colors
if colors.length is 4 and colors.every (color) => invisible color
cmdr.callbacks.push (api: AppletObject) => api.setVisible name, false
@ -243,11 +294,11 @@ function jToG(
console.log 'Fading out interior of', face if trace
// hide the interior by making it transparent
api.setFilling face, 0
else if face not in elements
else if face not in cdata.elements
console.log 'Hiding face', face if trace
api.setVisible face, false
else
faceRGB := joyce2rgb(colors[3] or 'brighter', backgroundRGB)
faceRGB := joyce2rgb(colors[3] or 'brighter', cdata.bg)
deep := ['circle', 'polygon', 'sector']
filling := deep.includes(klass) ? 0.7 : 0.2
for each face of parts[2]
@ -259,28 +310,30 @@ function jToG(
// Lines default to black:
if invisible colors[2]
for each line of parts[1]
if line is name or line not in elements
if line is name or line not in cdata.elements
console.log 'Hiding line', line if trace
api.setVisible line, false
else
lineRGB := joyce2rgb(colors[2] or 'black', backgroundRGB)
lineRGB := joyce2rgb(colors[2] or 'black', cdata.bg)
for each line of parts[1]
console.log 'Coloring line', line, 'to', colors[2] if trace
api.setVisible line, true
api.setColor line, ...lineRGB
// Now color the points:
console.log 'Considering point colors for', name if trace
if trace
console.log
'Considering point colors for', name, 'of dimension', dimension
if invisible colors[1]
// Hide all the dim-0 elements that are not distinct independent
// items:
for each point of parts[0]
if point is name or point not in elements
if point is name or point not in cdata.elements
console.log 'Hiding point', point if trace
api.setVisible point, false
else if dimension is 0 or colors[1] // Need to color the points
ptRGB := colors[1] ? joyce2rgb colors[1], backgroundRGB
: pointDefaultRGB name, method
ptRGB := colors[1] ? joyce2rgb colors[1], cdata.bg
: pointDefaultRGB name, method, isPivot
for each point of parts[0]
console.log 'Coloring point', point, 'to', colors[1] if trace
api.setVisible point, true
@ -290,12 +343,12 @@ function jToG(
if invisible colors[0]
console.log 'Hiding label', name if trace
api.setLabelVisible name, false
else if colors[dimension] and colors[dimension] is not colors[0]
else if colors[dimension+1] and colors[dimension+1] is not colors[0]
// Have to make a text to provide the caption, since GeoGebra
// doesn't allow caption different color from entity.
textName := 'GeoText' + index
locationExpr := switch klass
when 'point' then `1.02${name})`
when 'point' then `1.02${name}`
when 'line'
(`Midpoint(${name}) + `
+ `Rotate(Direction(${name})*Length(${name})*0.02, pi/2)`)
@ -315,8 +368,12 @@ function jToG(
unless parts[0].includes ex1 then ex1 = `Centroid(${ex1})`
unless parts[0].includes ex2 then ex2 = `Centroid(${ex2})`
`(4*${ex1}+${ex2})/5`
api.evalCommand `${textName} = Text("${jname}", ${locationExpr})`
api.setColor textName, ...joyce2rgb colors[0], backgroundRGB
textCmd := `${textName} = Text("${jname}", ${locationExpr})`
textCol := joyce2rgb colors[0], cdata.bg
if trace
console.log `Making text '${textCmd}' colored`, textCol
api.evalCommand textCmd
api.setColor textName, ...textCol
// and hide the underlying GeoGebra label
api.setLabelVisible name, false
else if colors[0]
@ -331,19 +388,35 @@ function jToG(
console.log 'Setting label vis of', name, 'to', show if trace
api.setLabelVisible name, show
// window[hideListener] = (arg) =>
// api.setVisible name, false
// api.registerObjectUpdateListener name, hideListener
if cmdr.ends // line or sector
elements[jname] =
{otherName: name, usesCaptions, klass, cmdr.ends}
elements[name] =
{otherName: jname, usesCaptions, klass, cmdr.ends}
else // any other geometry
elements[jname] = {otherName: name, usesCaptions, klass}
elements[name] = {otherName: jname, usesCaptions, klass}
cdata.elements[jname] = {otherName: name, usesCaptions, klass, cmdr.ends}
cdata.elements[name] = {otherName: jname, usesCaptions, klass, cmdr.ends}
cmdr
function pivotListener(slider: string)
pd := pivotData[slider]
newval := pd.api.getValue slider
if newval is pd.lastAngle then return
rotation := newval - pd.lastAngle
pd.lastAngle = newval
pX := pd.api.getXcoord pd.pivot
pY := pd.api.getYcoord pd.pivot
relX: Record<string, number> := {}
relY: Record<string, number> := {}
for each anchor of pd.rotatable
relX[anchor] = pd.api.getXcoord(anchor) - pX
relY[anchor] = pd.api.getYcoord(anchor) - pY
console.log 'Unfixing', anchor, relX[anchor], relY[anchor]
pd.api.setFixed anchor, false
ct := Math.cos rotation
st := Math.sin rotation
for each anchor of pd.rotatable
rX := relX[anchor]*ct - relY[anchor]*st
rY := relY[anchor]*ct + relX[anchor]*st
pd.api.setCoords(anchor, rX + pX, rY + pY)
if pd.fixed[anchor]
console.log 'Re-fixing', anchor, rX, rY
pd.api.setFixed anchor, true
function invisible(cname: string): boolean
if adapParams.config?.showall then return false
cname is '0' or cname is 'none'
@ -397,8 +470,8 @@ function joyce2rgb(cname: string, backgroundRGB?: RGB): RGB
console.warn 'Could not parse color:', cname
[128, 128, 128]
function pointDefaultRGB(name: string, method: string): RGB
// Need to short-circuit with green for pivot point, once that is implemented
function pointDefaultRGB(name: string, method: string, isPivot: boolean): RGB
if isPivot then return joyce2rgb 'green'
switch method
'free'
joyce2rgb 'red'
@ -406,7 +479,14 @@ function pointDefaultRGB(name: string, method: string): RGB
joyce2rgb 'orange'
else joyce2rgb 'black'
function geoname(jname: JoyceName, elements: JoyceElements): GeoName
function geoname(
jname: JoyceName, elements: JoyceElements, klass: JoyceClass): GeoName
unless jname.substring(0,3) is 'Geo' // those might clash
// Names with word characters starting with a capital are always good:
if /^[A-Z]['\w]*$/.test jname then return jname
// If it's not a point, can start with any letter:
if klass !== 'point' and /^\p{L}['\w]*$/u.test jname then return jname
// GeoGebra won't deal with this name, so hash it:
numCode .= 0n
numCode = numCode*128n + BigInt ch.codePointAt(0) ?? 1 for each ch of jname
return .= 'Geo' + numCode.toString(36);
@ -426,11 +506,12 @@ function cutoffExtend(
// All of the detailed semantics of each available command lies in this
// function.
classHandler: Record<JoyceClass, ClassHandler> :=
point: (name, method, args, index, is3d): Commander =>
point: (name, method, args, index, cdata): Commander =>
return := freshCommander()
{commands, callbacks, parts, auxiliaries} := return.value
zeroVector := is3d ? 'Vector((0,0,0))' : 'Vector((0,0))'
zeroVector := cdata.is3d ? 'Vector((0,0,0))' : 'Vector((0,0))'
aux := name + 'aUx'
pivotable := cdata.pivot and name !== pivotData[cdata.pivot].pivot
parts[0].push name
switch method
/angle(?:Bisector|Divider)/
@ -445,7 +526,7 @@ classHandler: Record<JoyceClass, ClassHandler> :=
commands.push `${destination} = Segment(${start}, ${end})`
else destination = args.line[0]
n := method is 'angleBisector' ? 2 : args.scalar?[0]
inPlane := is3d ? `, Plane(${start}, ${center}, ${end})` : ''
inPlane := cdata.is3d ? `, Plane(${start}, ${center}, ${end})` : ''
commands.push
`${aux}2 = Angle(${start}, ${center}, ${end}${inPlane})`
`${aux}3 = If(${aux}2 > pi, ${aux}2 - 2*pi, ${aux}2)`
@ -454,7 +535,12 @@ classHandler: Record<JoyceClass, ClassHandler> :=
auxiliaries.push ...[2..4].map (i) => `${aux}${i}`
'circleSlider'
unless args.circle then return
commands.push `${name} = Point(${args.circle[0]})`
circ := args.circle[0]
commands.push `${name} = Point(${circ})`
if (pivotable
and cdata.elements[circ].ends?[0] // center
is pivotData[cdata.pivot].pivot)
pivotData[cdata.pivot].rotatable.push name
if args.scalar and args.scalar.length
callbacks.push (api: AppletObject) =>
api.setCoords name, ...args.scalar as XYZ
@ -466,12 +552,15 @@ classHandler: Record<JoyceClass, ClassHandler> :=
commands.push `${name} = Translate(${source}, ${displacement})`
'first'
unless args.subpoints then return
commands.push `${name} = Point(${args.subpoints[0]},${zeroVector})`
commands.push `${name} = ${args.subpoints[0]}`
/fixed|free/
coords := args.scalar
unless coords then return
commands.push `${name} = Point({${coords.join ','}})`
scoord := coords.join ','
if pivotable then pivotData[cdata.pivot].rotatable.push name
commands.push `${name} = (${scoord})`
if method is 'fixed'
if pivotable then pivotData[cdata.pivot].fixed[name] = true
callbacks.push (api: AppletObject) => api.setFixed name, true
'foot'
pt := args.subpoints
@ -492,7 +581,7 @@ classHandler: Record<JoyceClass, ClassHandler> :=
'last'
unless args.subpoints then return
commands.push
`${name} = Point(${args.subpoints.at(-1)}, ${zeroVector})`
`${name} = ${args.subpoints.at(-1)}`
'lineSegmentSlider'
segment .= args.line?[0]
unless segment
@ -524,7 +613,7 @@ classHandler: Record<JoyceClass, ClassHandler> :=
unless pt.length is 5 then return
sourcePlane .= ''
destPlane .= ''
if is3d
if cdata.is3d
unless args.plane then return
destPlane = `, ${args.plane[0]}`
if args.plane.length > 1
@ -544,7 +633,7 @@ classHandler: Record<JoyceClass, ClassHandler> :=
commands.push
`${name} = Vertex(${args.polygon?[0]},${args.scalar?[0]})`
line: (name, method, args, index, is3d) =>
line: (name, method, args, index, cdata) =>
return := freshCommander()
return.value.ends = ['', '']
{commands, callbacks, parts, auxiliaries, ends} := return.value
@ -561,7 +650,7 @@ classHandler: Record<JoyceClass, ClassHandler> :=
unless cr return
commands.push ...[1..2].map (n) =>
`${aux}${n} = Intersect(${cr[0]}, ${cr[1]}, ${n})`
inPlane := is3d ? `Plane(${cr[0]})` : ''
inPlane := cdata.is3d ? `Plane(${cr[0]})` : ''
ctr := cr.map (c) => `Center(${c})`
condition := (`Angle(${aux}1,${ctr[0]},${ctr[1]}${inPlane})`
+ `< Angle(${aux}2,${ctr[0]},${ctr[1]}${inPlane})`)
@ -630,7 +719,8 @@ classHandler: Record<JoyceClass, ClassHandler> :=
circle: (name, method, args) =>
return := freshCommander()
{commands, callbacks, parts, auxiliaries} := return.value
return.value.ends = ['', '']
{commands, callbacks, parts, auxiliaries, ends} := return.value
parts[2].push name
parts[1].push name
switch method
@ -641,10 +731,12 @@ classHandler: Record<JoyceClass, ClassHandler> :=
switch pt.length
when 2
[center, point] := pt
ends[0] = center
commands.push
`${name} = Circle(${center}, ${point}${inPlane})`
when 3
center := pt[0]
ends[0] = center
radius := `Distance(${pt[1]}, ${pt[2]})`
commands.push
`${name} = Circle(${center}, ${radius}${inPlane})`