Problem Statement
The library only lays out menus sensibly at 4 or 8 items per level, and you have no say in where an item sits.
An item's angle comes entirely from its position in the list: index * 90 for levels of 4 items or fewer, index * 45 beyond that. Two problems follow.
A level of 5, 6, or 7 items crowds every item into one half or three quarters of the circle. A 5-item menu puts all five items between 0 and 180 degrees and leaves the whole left side empty. The gestures for those items sit close together, which is the situation marking menus exist to avoid: both selection accuracy and how well people remember a gesture depend on the directions being far apart.
A level of more than 8 items is not just discouraged, it is broken. The ninth item gets 360 degrees, the tenth gets 405, and so on, so items quietly land on top of earlier ones.
There is also no way to say "put this item at the top" or "lay these out every 30 degrees". Directions in a marking menu are the interface, because people learn them as gestures. An application that wants an item pointing north, or wants its menu to line up with directions it uses elsewhere, cannot ask for it.
Solution
An item can state the angle it wants, and items that don't state one spread evenly between those that do.
You add an optional angle to any item, in degrees, clockwise from east. Those are the same units the model already hands back to you. An item with an angle goes exactly there. Every other item spreads evenly across the space left between them, in the order you listed them.
The rule fits in a sentence and you can work it out on paper: free items sitting in a gap are placed at even steps across it, dividing the gap by one more than the number of free items in it. A level where nothing states an angle is a single 360 degree gap, so it lays out evenly at 360/n. That reproduces today's 4- and 8-item menus exactly, fixes the crowding at 5, 6, and 7, and makes any number of items work.
Listing order still means clockwise order, so the convention you already rely on doesn't change.
User Stories
- As an application developer, I want to give an item an explicit angle, so that a command people use often always sits in the direction they have learned.
- As an application developer, I want to give angles to only some items, so that I can pin down the ones that matter and let the library place the rest.
- As an application developer, I want the items I didn't place to spread evenly across the space between the ones I did, so that I don't have to work out angles by hand for a menu that is mostly conventional.
- As an application developer, I want a menu with 5, 6, or 7 items per level to use the whole circle, so that people aren't asked to tell apart directions 45 degrees away from each other when there is empty space going spare.
- As an application developer, I want to build a menu with more than 8 items per level, so that I don't have to invent a sub-menu just to get around a layout limit.
- As an application developer, I want a menu with fewer than 4 items to use the whole circle, so that a 3-item menu gives three well-separated directions instead of three crammed into a half.
- As an application developer, I want listing order to still mean clockwise order when I supply angles, so that the convention I already know still holds.
- As an application developer, I want to list angles that wrap past zero, such as an item at 270 degrees followed by one at 0, so that I can start a menu at the top without reordering my list.
- As an application developer, I want a single angle on a level to rotate that whole level, so that "the first item points north, then spread the rest out" works without annotating every item.
- As an application developer, I want to read each item's final angle back from the model, so that I can check where the library put the items I left free.
- As an application developer, I want the angles I read back to sit between 0 and 360 degrees, so that I can compare and draw them without handling wrap-around myself.
- As an application developer, I want a clear error when two items on a level end up at the same angle, so that I find the collision when I build the menu rather than by watching selections go to the wrong item.
- As an application developer, I want a clear error when the angles on a level would need more than one full turn, so that I learn my angles disagree with my listing order instead of getting a mangled layout.
- As an application developer, I want a clear error when I pass an angle that isn't a real number, so that a mistake further up my own code surfaces where I can act on it.
- As an application developer, I want a clear error when a level is packed too tightly for the recognizer to tell items apart, so that I don't ship a menu whose gestures can't be distinguished.
- As an application developer, I want angles to work the same way at every level, so that I don't have to learn one rule for the top level and another for sub-menus.
- As someone using the menu, I want the items of a 5-, 6-, or 7-item menu spread around the circle, so that the directions are far enough apart that I get the item I meant.
- As someone using the menu, I want my stroke recognized as accurately on an unevenly spaced menu as on an even one, so that using angles doesn't cost me reliability.
- As someone drawing a stroke through several levels, I want the corners between levels picked up correctly when neighboring items sit closer together than an even layout would put them, so that a deep selection isn't flattened into a shallow one.
- As someone using the menu, I want a stroke on a widely spaced menu not to be chopped into extra levels by ordinary hand wobble, so that one selection isn't read as two.
- As someone new to a menu, reading the labels on screen, I want the item I'm pointing at to stay readable when labels overlap, so that a tightly packed menu is still usable.
- As someone using the menu, I want items placed diagonally drawn with the same corner styling as before, so that the menu looks consistent whatever the exact angles are.
- As a maintainer, I want the recognizer's corner threshold worked out from the menu's actual smallest gap, so that the assumption the threshold rests on holds for every layout the library can now produce.
- As a maintainer, I want a generated stroke corpus covering different item counts and angle setups, so that a change to the layout rules is checked against recognition rather than assumed safe.
- As a maintainer, I want the corpus noise tuned against how the library behaves today, so that the corpus is neither too clean to fail nor unfairly harsh.
- As a maintainer, I want the recorded strokes already in the repository to keep passing, so that the change is demonstrably safe for the layouts people have today.
- As someone upgrading the library, I want the item movement in 5- to 7-item menus called out loudly, so that I know the gestures my users have learned will now pick different items.
- As an application developer, I want a page where I can type an item list with angles and see the resulting menu, so that I can work out what a menu will look like before writing it into my application.
- As an application developer, I want to draw a stroke on that page and see the corners the recognizer found and the pieces it cut my stroke into, so that I can understand why a gesture was read the way it was.
- As an application developer, I want to build a menu on that page with controls rather than by writing code, so that I can try a layout out without setting up a project.
- As an application developer, I want the controls and the address to stay in step, so that I can send someone a link that shows the exact menu I am asking about, and change one angle on a link somebody sent me.
- As an application developer reporting a problem, I want to attach a link that reproduces my layout, so that a bug report doesn't depend on me describing my item list in prose.
- As a maintainer, I want that same page while building this work, so that I can see what a layout rule does before deciding whether it is right.
Implementation Decisions
Item input gains an optional angle in degrees, clockwise from east, where 0 points right. Those are the units the model already reports. It works the same at every level. There is no type-level checking of angles: they are numbers, so a check on literal types would catch only trivial mistakes while costing a large amount of the type machinery in the model module.
Listing order stays clockwise order. Angles are unwrapped as the level is walked, so each one is greater than the one before it modulo 360. An item at 270 degrees followed by one at 0 therefore reads as 270 then 360, wrapping once around. A level that would need more than one full turn is an error.
Free items spread evenly inside each gap between two stated angles. For a gap of width d holding k free items, the free items go at even steps of d/(k+1) across it. Two results of that rule matter enough to test directly rather than leave implied. A level where nothing states an angle is a single 360 degree gap, which gives 360/n and reproduces the 4-item and 8-item layouts exactly while defining what every other count does. A level with exactly one stated angle is that same even circle, turned so that one item lands on the stated angle.
An earlier version of this design snapped free items to a grid built by repeatedly halving 90 degrees, with the grid rotated to fit whatever angles you stated. It needed four rules working together: where the grid's origin comes from when several items state angles, how fine the grid gets on each level, what happens when stated angles share no common grid, and how to spread items across the slots that are left. The result was that if you stated two angles you could not tell where your free items would end up without running the algorithm. All it bought over even spacing was putting 5-, 6-, and 7-item menus on multiples of 45 degrees instead of 72, 60, and 51.4. Nothing in the recognizer or the drawing code prefers multiples of 45, so the simpler rule wins.
The model module throws when you build the menu, the same way it already does for duplicate ids, in four cases: an angle that isn't a finite number, two items on a level landing on the same angle, a level whose stated angles need more than one full turn, and a level packed tighter than the recognizer can resolve. The angle reported back for each item sits between 0 and 360 degrees.
The recognizer's corner threshold is currently 360 / maxMenuBreadth / 2 / sensitivity. That formula assumes items are evenly spaced, so that 360/breadth is the step between neighbors and half a step is the boundary between them. This change breaks that assumption, and not only for menus using the new feature: any menu mixing stated and free angles has gaps that the item count alone doesn't predict. The threshold now comes from the model's smallest actual gap instead. On any even level the two numbers are identical, so 4- and 8-item menus are untouched. This doesn't introduce a new whole-menu dependency either, because the item count it replaces is already a whole-menu maximum, and on even menus 360/maxBreadth is exactly that smallest gap. The change computes the same quantity, correctly, when levels are uneven.
sensitivity stays private. It does not become a public option. Trying out different thresholds during development goes through the articulation point function, which the recognizer already exports.
The smallest angle the recognizer can resolve is exported from the recognizer module and used by the model module when it builds a menu. The dependency is real rather than accidental, because a layout is too crowded precisely when the recognizer can't separate it, and building the menu is the only moment you can do anything about the error. This means the model module imports from the recognizer module, a direction that doesn't exist today, so check for an import cycle before committing to it. The number itself comes out of the generated corpus, so write this check after the corpus exists.
The layout module's corner class map keys on the exact angles 45, 135, 225, and 315. With free-form angles most items would match nothing and get styled as if they pointed along an axis. It becomes a quadrant test instead: an angle strictly inside a quadrant gets that quadrant's class. That changes nothing for existing layouts, since 45 degrees is still bottom right and 0, 90, 180, and 270 are still unclassified, and it behaves sensibly for arbitrary angles. The class squares off the corner where the connecting line leaves the label, which depends on the quadrant, not on the exact angle.
Overlapping labels need no new handling. The stylesheet already raises the item you're pointing at above the others, so the label that matters stays readable. That carries over to uneven angles unchanged.
A page for trying menus out ships alongside the existing demo, as a second page in the same build rather than as a mode of the demo itself. The demo's job is to answer "what is a marking menu" for someone who has never seen one, and a form full of angle inputs works against that. The new page gives you a simple set of controls for building a menu: add and remove items, edit a label, set an angle on an item or leave it free, and do the same for sub-menus. The menu redraws as you go, and you can draw strokes against it. It also draws what the recognizer did: the corners it found, the pieces it cut the stroke into, and the threshold that produced them. That view is the only way to see threshold behavior rather than infer it.
The controls and the address stay in step in both directions. Changing something in the controls updates the address, and opening an address fills the controls in. So you can build a menu by clicking, then send the link; or open someone else's link and change one angle to see what happens. For a library where "here is the layout I got and here is what I expected" is the natural bug report, that pays for itself. Keep the encoding readable enough that a person can edit it by hand, and treat it as part of the page rather than as a published format: it may change while the input shape does. The page does not reach into test-only code, so it does not display the generated corpus.
The release is a major version. Nothing is removed and angle is optional, but items move on 5-, 6-, and 7-item menus. A gesture someone learned will now pick a different item, quietly and without an error anywhere. For a library whose whole point is getting people from reading menus to drawing remembered gestures, that is the most serious kind of break there is. The README needs rewriting in two places: "supports up to 8 items per level", and "the first item is on the right and the followings are layed out clockwise", which is now only true when no angles are stated.
Testing Decisions
A good test here checks what a caller can see: the angles the model reports, whether building the menu throws, whether a stroke resolves to the item it should, and what the menu draws. None of them should reach into how the layout is worked out. There is no grid, origin, or granularity to observe, and gap walking should stay replaceable.
Three seams, all of which already exist. No new ones in the shipped code.
createModel carries every layout decision and every error. The model tests already do exactly this: they assert menu.items.map((item) => item.angle) against expected arrays, and they already assert throwing on duplicate ids, which the new errors follow. Cover the counts with no stated angles (3, 4, 5, 6, 7, 8, and one comfortably past 8), a single stated angle turning a level, several stated angles with free items between them, angles that wrap past zero, angles on a deeper level, and one test for each error.
recognizeMarkingMenuStroke carries the threshold change and the corpus. The recognizer tests already read recorded strokes from CSV files named after the path they were drawn for, and assert the item sequence that comes back, in "recognizes real 1 level strokes" and "recognizes real 3 levels strokes". Those 13 recorded strokes only cover paths on multiples of 45 degrees, but they are the baseline and must keep passing untouched.
The generated corpus extends that same seam. A test-only generator, sitting alongside the recorded strokes, produces stroke points for a known path through a given model, with wobble added. Tune the wobble until the current library scores on today's even menus roughly where a real person would score, then freeze it and use it to judge the new layouts. That is what stops the corpus being too clean to fail, and it means nobody has to sit and draw strokes before the work can start. The corpus sweeps item counts and angle setups, which hand-drawn strokes can't do systematically, and it regenerates when the layout rules change.
The bar for merging is no regression on 4- and 8-item menus plus a floor on accuracy for the new layouts. If no threshold makes the new layouts recognizable, that is evidence against the layout rule rather than against the threshold, and the right response is to revisit the design, not to lower the floor.
createMenu carries the quadrant corner classes through the snapshot tests already in the layout module. Those tests build menus from stand-in layout models with arbitrary angles, angle: i * 10, so uneven angles already run through them.
Out of Scope
Recording new strokes. The 13 CSV files already in the repository are the baseline, and a fresh set drawn by one person is weak evidence either way. Worth doing later. It doesn't gate this work.
Making sensitivity a public option. Trying out thresholds is development work against the articulation point function the recognizer already exports, not something the test suite needs to keep doing.
Per-level recognizer thresholds. Articulation points are found before the tree is walked, so a threshold that varies by level isn't available without restructuring the recognizer. The smallest gap stays a whole-menu number, exactly as the item count it replaces already is.
Handling overlapping labels. No pushing labels outward, no layout adjustment. Raising the item you're pointing at is the whole strategy.
Exposing how the layout was worked out. No origin, granularity, or gap structure on the model. The angle on each item is the answer.
Showing the generated stroke corpus on the page for trying menus out. The page uses the shipped library only and does not depend on test-only code.
Type-level checking of angles.
Further Notes
One limitation worth writing down: when you state angles, you can't always tell where the free items land without running the code, because the gaps your angles create set the spacing. Even spacing keeps this as small as it can be, since dividing a gap by one more than the number of items in it is something you can do in your head. But reading item.angle back only tells you what happened, not what will happen, and the documentation shouldn't pretend otherwise. Worked examples in the README are part of the fix, and the page for trying menus out is the rest of it: you can see where your items land without writing any code.
The crowding check's number deliberately comes last. It is the one value in this design with no source other than measurement, and inventing a constant early would make it permanent by accident.
The model module importing from the recognizer module is the only change to how the modules relate to each other. If that turns out to be a cycle, pull the shared number out into a third place rather than copying it, since the two have to stay in step.
Problem Statement
The library only lays out menus sensibly at 4 or 8 items per level, and you have no say in where an item sits.
An item's angle comes entirely from its position in the list:
index * 90for levels of 4 items or fewer,index * 45beyond that. Two problems follow.A level of 5, 6, or 7 items crowds every item into one half or three quarters of the circle. A 5-item menu puts all five items between 0 and 180 degrees and leaves the whole left side empty. The gestures for those items sit close together, which is the situation marking menus exist to avoid: both selection accuracy and how well people remember a gesture depend on the directions being far apart.
A level of more than 8 items is not just discouraged, it is broken. The ninth item gets 360 degrees, the tenth gets 405, and so on, so items quietly land on top of earlier ones.
There is also no way to say "put this item at the top" or "lay these out every 30 degrees". Directions in a marking menu are the interface, because people learn them as gestures. An application that wants an item pointing north, or wants its menu to line up with directions it uses elsewhere, cannot ask for it.
Solution
An item can state the angle it wants, and items that don't state one spread evenly between those that do.
You add an optional
angleto any item, in degrees, clockwise from east. Those are the same units the model already hands back to you. An item with an angle goes exactly there. Every other item spreads evenly across the space left between them, in the order you listed them.The rule fits in a sentence and you can work it out on paper: free items sitting in a gap are placed at even steps across it, dividing the gap by one more than the number of free items in it. A level where nothing states an angle is a single 360 degree gap, so it lays out evenly at
360/n. That reproduces today's 4- and 8-item menus exactly, fixes the crowding at 5, 6, and 7, and makes any number of items work.Listing order still means clockwise order, so the convention you already rely on doesn't change.
User Stories
Implementation Decisions
Item input gains an optional
anglein degrees, clockwise from east, where 0 points right. Those are the units the model already reports. It works the same at every level. There is no type-level checking of angles: they are numbers, so a check on literal types would catch only trivial mistakes while costing a large amount of the type machinery in the model module.Listing order stays clockwise order. Angles are unwrapped as the level is walked, so each one is greater than the one before it modulo 360. An item at 270 degrees followed by one at 0 therefore reads as 270 then 360, wrapping once around. A level that would need more than one full turn is an error.
Free items spread evenly inside each gap between two stated angles. For a gap of width
dholdingkfree items, the free items go at even steps ofd/(k+1)across it. Two results of that rule matter enough to test directly rather than leave implied. A level where nothing states an angle is a single 360 degree gap, which gives360/nand reproduces the 4-item and 8-item layouts exactly while defining what every other count does. A level with exactly one stated angle is that same even circle, turned so that one item lands on the stated angle.An earlier version of this design snapped free items to a grid built by repeatedly halving 90 degrees, with the grid rotated to fit whatever angles you stated. It needed four rules working together: where the grid's origin comes from when several items state angles, how fine the grid gets on each level, what happens when stated angles share no common grid, and how to spread items across the slots that are left. The result was that if you stated two angles you could not tell where your free items would end up without running the algorithm. All it bought over even spacing was putting 5-, 6-, and 7-item menus on multiples of 45 degrees instead of 72, 60, and 51.4. Nothing in the recognizer or the drawing code prefers multiples of 45, so the simpler rule wins.
The model module throws when you build the menu, the same way it already does for duplicate ids, in four cases: an angle that isn't a finite number, two items on a level landing on the same angle, a level whose stated angles need more than one full turn, and a level packed tighter than the recognizer can resolve. The angle reported back for each item sits between 0 and 360 degrees.
The recognizer's corner threshold is currently
360 / maxMenuBreadth / 2 / sensitivity. That formula assumes items are evenly spaced, so that360/breadthis the step between neighbors and half a step is the boundary between them. This change breaks that assumption, and not only for menus using the new feature: any menu mixing stated and free angles has gaps that the item count alone doesn't predict. The threshold now comes from the model's smallest actual gap instead. On any even level the two numbers are identical, so 4- and 8-item menus are untouched. This doesn't introduce a new whole-menu dependency either, because the item count it replaces is already a whole-menu maximum, and on even menus360/maxBreadthis exactly that smallest gap. The change computes the same quantity, correctly, when levels are uneven.sensitivitystays private. It does not become a public option. Trying out different thresholds during development goes through the articulation point function, which the recognizer already exports.The smallest angle the recognizer can resolve is exported from the recognizer module and used by the model module when it builds a menu. The dependency is real rather than accidental, because a layout is too crowded precisely when the recognizer can't separate it, and building the menu is the only moment you can do anything about the error. This means the model module imports from the recognizer module, a direction that doesn't exist today, so check for an import cycle before committing to it. The number itself comes out of the generated corpus, so write this check after the corpus exists.
The layout module's corner class map keys on the exact angles 45, 135, 225, and 315. With free-form angles most items would match nothing and get styled as if they pointed along an axis. It becomes a quadrant test instead: an angle strictly inside a quadrant gets that quadrant's class. That changes nothing for existing layouts, since 45 degrees is still bottom right and 0, 90, 180, and 270 are still unclassified, and it behaves sensibly for arbitrary angles. The class squares off the corner where the connecting line leaves the label, which depends on the quadrant, not on the exact angle.
Overlapping labels need no new handling. The stylesheet already raises the item you're pointing at above the others, so the label that matters stays readable. That carries over to uneven angles unchanged.
A page for trying menus out ships alongside the existing demo, as a second page in the same build rather than as a mode of the demo itself. The demo's job is to answer "what is a marking menu" for someone who has never seen one, and a form full of angle inputs works against that. The new page gives you a simple set of controls for building a menu: add and remove items, edit a label, set an angle on an item or leave it free, and do the same for sub-menus. The menu redraws as you go, and you can draw strokes against it. It also draws what the recognizer did: the corners it found, the pieces it cut the stroke into, and the threshold that produced them. That view is the only way to see threshold behavior rather than infer it.
The controls and the address stay in step in both directions. Changing something in the controls updates the address, and opening an address fills the controls in. So you can build a menu by clicking, then send the link; or open someone else's link and change one angle to see what happens. For a library where "here is the layout I got and here is what I expected" is the natural bug report, that pays for itself. Keep the encoding readable enough that a person can edit it by hand, and treat it as part of the page rather than as a published format: it may change while the input shape does. The page does not reach into test-only code, so it does not display the generated corpus.
The release is a major version. Nothing is removed and
angleis optional, but items move on 5-, 6-, and 7-item menus. A gesture someone learned will now pick a different item, quietly and without an error anywhere. For a library whose whole point is getting people from reading menus to drawing remembered gestures, that is the most serious kind of break there is. The README needs rewriting in two places: "supports up to 8 items per level", and "the first item is on the right and the followings are layed out clockwise", which is now only true when no angles are stated.Testing Decisions
A good test here checks what a caller can see: the angles the model reports, whether building the menu throws, whether a stroke resolves to the item it should, and what the menu draws. None of them should reach into how the layout is worked out. There is no grid, origin, or granularity to observe, and gap walking should stay replaceable.
Three seams, all of which already exist. No new ones in the shipped code.
createModelcarries every layout decision and every error. The model tests already do exactly this: they assertmenu.items.map((item) => item.angle)against expected arrays, and they already assert throwing on duplicate ids, which the new errors follow. Cover the counts with no stated angles (3, 4, 5, 6, 7, 8, and one comfortably past 8), a single stated angle turning a level, several stated angles with free items between them, angles that wrap past zero, angles on a deeper level, and one test for each error.recognizeMarkingMenuStrokecarries the threshold change and the corpus. The recognizer tests already read recorded strokes from CSV files named after the path they were drawn for, and assert the item sequence that comes back, in "recognizes real 1 level strokes" and "recognizes real 3 levels strokes". Those 13 recorded strokes only cover paths on multiples of 45 degrees, but they are the baseline and must keep passing untouched.The generated corpus extends that same seam. A test-only generator, sitting alongside the recorded strokes, produces stroke points for a known path through a given model, with wobble added. Tune the wobble until the current library scores on today's even menus roughly where a real person would score, then freeze it and use it to judge the new layouts. That is what stops the corpus being too clean to fail, and it means nobody has to sit and draw strokes before the work can start. The corpus sweeps item counts and angle setups, which hand-drawn strokes can't do systematically, and it regenerates when the layout rules change.
The bar for merging is no regression on 4- and 8-item menus plus a floor on accuracy for the new layouts. If no threshold makes the new layouts recognizable, that is evidence against the layout rule rather than against the threshold, and the right response is to revisit the design, not to lower the floor.
createMenucarries the quadrant corner classes through the snapshot tests already in the layout module. Those tests build menus from stand-in layout models with arbitrary angles,angle: i * 10, so uneven angles already run through them.Out of Scope
Recording new strokes. The 13 CSV files already in the repository are the baseline, and a fresh set drawn by one person is weak evidence either way. Worth doing later. It doesn't gate this work.
Making
sensitivitya public option. Trying out thresholds is development work against the articulation point function the recognizer already exports, not something the test suite needs to keep doing.Per-level recognizer thresholds. Articulation points are found before the tree is walked, so a threshold that varies by level isn't available without restructuring the recognizer. The smallest gap stays a whole-menu number, exactly as the item count it replaces already is.
Handling overlapping labels. No pushing labels outward, no layout adjustment. Raising the item you're pointing at is the whole strategy.
Exposing how the layout was worked out. No origin, granularity, or gap structure on the model. The angle on each item is the answer.
Showing the generated stroke corpus on the page for trying menus out. The page uses the shipped library only and does not depend on test-only code.
Type-level checking of angles.
Further Notes
One limitation worth writing down: when you state angles, you can't always tell where the free items land without running the code, because the gaps your angles create set the spacing. Even spacing keeps this as small as it can be, since dividing a gap by one more than the number of items in it is something you can do in your head. But reading
item.angleback only tells you what happened, not what will happen, and the documentation shouldn't pretend otherwise. Worked examples in the README are part of the fix, and the page for trying menus out is the rest of it: you can see where your items land without writing any code.The crowding check's number deliberately comes last. It is the one value in this design with no source other than measurement, and inventing a constant early would make it permanent by accident.
The model module importing from the recognizer module is the only change to how the modules relate to each other. If that turns out to be a cycle, pull the shared number out into a third place rather than copying it, since the two have to stay in step.