From 85ad82d9e25984432d1b8d03e1d3dde11e570732 Mon Sep 17 00:00:00 2001 From: Glen Whitney Date: Fri, 13 Oct 2023 10:17:16 -0700 Subject: [PATCH] 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. --- etc/deps/geotypes/api.ts | 2 +- src/adapptlet.civet | 216 ++++++++++++++++++++++++++++----------- 2 files changed, 155 insertions(+), 63 deletions(-) diff --git a/etc/deps/geotypes/api.ts b/etc/deps/geotypes/api.ts index 859b5c7..a0d6917 100644 --- a/etc/deps/geotypes/api.ts +++ b/etc/deps/geotypes/api.ts @@ -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; diff --git a/src/adapptlet.civet b/src/adapptlet.civet index a08a6a2..2542a4b 100644 --- a/src/adapptlet.civet +++ b/src/adapptlet.civet @@ -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 + +pivotData: Record := {} + 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 := {} + relY: Record := {} + 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 := - 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 := 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 := 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 := 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 := '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 := 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 := 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 := 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 := 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 := 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})`