Compare commits

..

10 Commits

Author SHA1 Message Date
Nathan McRae
4394d26184 Reverse axes 1 and 3
Because we want axis 1 to increase to the right, and axis 3 to increase
up
2025-09-03 21:40:39 -07:00
Nathan McRae
9859938df7 Swap typefaces 2025-09-03 21:27:27 -07:00
Nathan McRae
62fefb0cb1 Fix some alignment 2025-09-03 21:27:09 -07:00
Nathan McRae
6a4a0f5719 Add download button 2025-08-27 21:15:20 -07:00
Nathan McRae
5e795ac9f3 Fix alignment of even number of ticks
Without this, it created a hexagram type shape in the graph and the tick
marks didn't all intersect together at one point.
2025-08-24 19:03:26 -07:00
Nathan McRae
da1e6f32a5 Initial styling
Change from default increment/decrement buttons to custom ones since
that's easier to style.
2025-08-24 13:32:12 -07:00
Nathan McRae
6541d65f3a Add other graph parameters to interface 2025-08-20 22:55:11 -07:00
Nathan McRae
aa7b0813c4 Simplify adding event listeners 2025-08-20 22:42:20 -07:00
Nathan McRae
5b4cf834a0 Remove redundant code from main 2025-08-20 22:19:14 -07:00
Nathan McRae
2a4b5702af Add tick size control 2025-08-20 21:49:23 -07:00
6 changed files with 450 additions and 89 deletions

View File

@@ -1,10 +1,97 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Ternary Graph Generator</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="style.css"/>
<link rel="stylesheet" href="ternary-graph.css"/>
</head>
<body>
ticks:
<input type="number" id="ticks"/>
<div id="breadcrumb">
<a id="site-title" href="https://nathanmcrae.name">
<img class="avatar" src="avatar.png"/>
NathanMcRae.name
</a>
/
Ternary Graph Generator
</div>
<hr/>
<div id="main-content">
<div id="controls">
<div class="input">
<p>Axis title text size (px):</p>
<input type="number" id="axis-title-size" value="16"/>
<button id="axis-title-size-down"></button>
<button id="axis-title-size-up"></button>
</div>
<div class="input">
<p>Tick label text size (px):</p>
<input type="number" id="tick-label-size" value="12"/>
<button id="tick-label-size-down"></button>
<button id="tick-label-size-up"></button>
</div>
<div class="input">
<p>Ticks:</p>
<input type="number" id="ticks" value="9"/>
<button id="ticks-down"></button>
<button id="ticks-up"></button>
</div>
<div class="input">
<p>Tick size (px):</p>
<input type="number" id="tick-size" value="5"/>
<button id="tick-size-down"></button>
<button id="tick-size-up"></button>
</div>
<div class="group">
<h3>Axis 1</h3>
<div class="input">
<p>Title:</p>
<input id="axis-1-title" value="Axis 1"/>
</div>
<div class="input">
<p>Start value:</p>
<p>E</p>
<input type="number" id="axis-1-start" value="0"/>
<button id="axis-1-start-down"></button>
<button id="axis-1-start-up"></button>
</div>
</div>
<div class="group">
<h3>Axis 2</h3>
<div class="input">
<p>Title:</p>
<input id="axis-2-title" value="Axis 2"/>
</div>
<div class="input">
<p>Start value:</p>
<p>E</p>
<input type="number" id="axis-2-start" value="0"/>
<button id="axis-2-start-down"></button>
<button id="axis-2-start-up"></button>
</div>
</div>
<div class="group">
<h3>Axis 3</h3>
<div class="input">
<p>Title:</p>
<input id="axis-3-title" value="Axis 3"/>
</div>
<div class="input">
<p>Start value:</p>
<p>E</p>
<input type="number" id="axis-3-start" value="0"/>
<button id="axis-3-start-down"></button>
<button id="axis-3-start-up"></button>
</div>
</div>
<a id="download-button">
<button>Download SVG</button>
</a>
</div>
<div id="svg-container" class="my-class"></div>
</div>
</body>
<script src="index.js"></script>
</html>

View File

@@ -19,6 +19,7 @@ to generate this file without the comments in this block.
, "exceptions"
, "foldable-traversable"
, "integers"
, "js-uri"
, "lists"
, "maybe"
, "numbers"

View File

@@ -15,7 +15,9 @@ import Effect (Effect)
import Effect.Console (log)
import Effect.Class (liftEffect)
import Effect.Exception (throw)
import JSURI (encodeURIComponent)
import TernaryGraph (Dimension, svgTextID, ternaryGraph, ternaryGraphSvg, TextStyle, tickLabelStrings)
import TernaryGraph as TernaryGraph
import Web.DOM.Document (contentType
, createElement
, Document
@@ -104,35 +106,92 @@ getAllTextDimensions document svgContainer strings = do
pure myMap
update :: Event -> Effect Unit
update e = do
updateEvent :: Event -> Effect Unit
updateEvent _ = do update
update :: Effect Unit
update = do
w <- window
d <- document w
let document = HTMLDoc.toDocument d
domParser <- makeDOMParser
log "update"
axis1TitleElMay <- myGetElementById document "axis-1-title"
axis1TitleEl <- case HTMLInput.fromElement axis1TitleElMay of
Nothing -> throw "'axis-1-title' element is not an input tag"
Just e -> pure e
axis1Title <- HTMLInput.value axis1TitleEl
axis1StartElMay <- myGetElementById document "axis-1-start"
axis1StartEl <- case HTMLInput.fromElement axis1StartElMay of
Nothing -> throw "'axis-1-start' element is not an input tag"
Just e -> pure e
axis1Start <- (liftM1 Int.round) $ HTMLInput.valueAsNumber axis1StartEl
axis2TitleElMay <- myGetElementById document "axis-2-title"
axis2TitleEl <- case HTMLInput.fromElement axis2TitleElMay of
Nothing -> throw "'axis-2-title' element is not an input tag"
Just e -> pure e
axis2Title <- HTMLInput.value axis2TitleEl
axis2StartElMay <- myGetElementById document "axis-2-start"
axis2StartEl <- case HTMLInput.fromElement axis2StartElMay of
Nothing -> throw "'axis-2-start' element is not an input tag"
Just e -> pure e
axis2Start <- (liftM1 Int.round) $ HTMLInput.valueAsNumber axis2StartEl
axis3TitleElMay <- myGetElementById document "axis-3-title"
axis3TitleEl <- case HTMLInput.fromElement axis3TitleElMay of
Nothing -> throw "'axis-3-title' element is not an input tag"
Just e -> pure e
axis3Title <- HTMLInput.value axis3TitleEl
axis3StartElMay <- myGetElementById document "axis-3-start"
axis3StartEl <- case HTMLInput.fromElement axis3StartElMay of
Nothing -> throw "'axis-3-start' element is not an input tag"
Just e -> pure e
axis3Start <- (liftM1 Int.round) $ HTMLInput.valueAsNumber axis3StartEl
inputElement <- myGetElementById document "ticks"
inputHTMLElement <- case HTMLInput.fromElement inputElement of
Nothing -> throw "'ticks' element is not an input tag"
Just e -> pure e
ticks <- (liftM1 Int.round) $ HTMLInput.valueAsNumber inputHTMLElement
tickSizeElMay <- myGetElementById document "tick-size"
tickSizeEl <- case HTMLInput.fromElement tickSizeElMay of
Nothing -> throw "'tick-size' element is not an input tag"
Just e -> pure e
tickSize <- HTMLInput.valueAsNumber tickSizeEl
axisTitleSizeElMay <- myGetElementById document "axis-title-size"
axisTitleSizeEl <- case HTMLInput.fromElement axisTitleSizeElMay of
Nothing -> throw "'axis-title-size' element is not an input tag"
Just e -> pure e
axisTitleSize <- HTMLInput.valueAsNumber axisTitleSizeEl
tickLabelSizeElMay <- myGetElementById document "tick-label-size"
tickLabelSizeEl <- case HTMLInput.fromElement tickLabelSizeElMay of
Nothing -> throw "'tick-label-size' element is not an input tag"
Just e -> pure e
tickLabelSize <- HTMLInput.valueAsNumber tickLabelSizeEl
svgContainer <- getNodeById document "svg-container"
let graphDef = { axis1Label: "axis 1"
, axis2Label: "axis 2"
, axis3Label: "axis 3"
, axis1Start: 0
, axis2Start: 1
, axis3Start: 20
let graphDef = { axis1Label: axis1Title
, axis2Label: axis2Title
, axis3Label: axis3Title
, axis1Start: axis1Start
, axis2Start: axis2Start
, axis3Start: axis3Start
, numTicks: ticks
, tickTextStyle: { sizePx: 12.0
, typeface: "Liberation Sans"
}
, axisTitleTextStyle: { sizePx: 16.0
, tickTextStyle: { sizePx: tickLabelSize
, typeface: "Liberation Mono"
}
, tickSize: TernaryGraph.Pixels tickSize
, axisTitleTextStyle: { sizePx: axisTitleSize
, typeface: "Liberation Sans"
}
}
let tickText = tickLabelStrings graphDef
@@ -170,67 +229,76 @@ update e = do
newNode <- importNode svgNode true document
appendChild newNode svgContainer
-- Set SVG content to download button
downloadButtonEl <- myGetElementById document "download-button"
encodedSVG <- case encodeURIComponent mySVG of
Nothing -> throw "Failed to encode SVG"
Just encoded -> pure encoded
let downloadText = "data:xml/svg;charset=utf-8," <> encodedSVG
Element.setAttribute "href" downloadText downloadButtonEl
Element.setAttribute "download" "ternary-graph.svg" downloadButtonEl
addUpdateListener :: Document -> String -> Effect Unit
addUpdateListener doc id = do
listener <- eventListener updateEvent
element <- myGetElementById doc id
addEventListener (EventType "input") listener true (Element.toEventTarget element)
incrementInput :: HTMLInput.HTMLInputElement -> Number -> Event -> Effect Unit
incrementInput input inc _ = do
currentVal <- HTMLInput.valueAsNumber input
HTMLInput.setValueAsNumber (currentVal + inc) input
update
registerInputIncButton :: Document -> String -> String -> Number -> Effect Unit
registerInputIncButton doc inputID buttonID inc = do
inputElMay <- myGetElementById doc inputID
inputEl <- case HTMLInput.fromElement inputElMay of
Nothing -> throw $ "'" <> inputID <> "' element is not an input tag"
Just e -> pure e
listener <- eventListener $ incrementInput inputEl inc
element <- myGetElementById doc buttonID
addEventListener (EventType "click") listener true (Element.toEventTarget element)
main :: Effect Unit
main = do
w <- window
d <- document w
let dd = HTMLDoc.toDocument d
domParser <- makeDOMParser
svgContainer <- getNodeById dd "svg-container"
addUpdateListener dd "axis-1-title"
addUpdateListener dd "axis-1-start"
addUpdateListener dd "axis-2-title"
addUpdateListener dd "axis-2-start"
addUpdateListener dd "axis-3-title"
addUpdateListener dd "axis-3-start"
let graphDef = { axis1Label: "axis 1"
, axis2Label: "axis 2"
, axis3Label: "axis 3"
, axis1Start: 0
, axis2Start: 1
, axis3Start: 20
, numTicks: 10
, tickTextStyle: { sizePx: 12.0
, typeface: "Liberation Sans"
}
, axisTitleTextStyle: { sizePx: 16.0
, typeface: "Liberation Mono"
}
}
addUpdateListener dd "axis-title-size"
addUpdateListener dd "tick-label-size"
let tickText = tickLabelStrings graphDef
addUpdateListener dd "ticks"
addUpdateListener dd "tick-size"
log $ Array.intercalate ",\n" (Set.toUnfoldable tickText)
registerInputIncButton dd "axis-1-start" "axis-1-start-up" 1.0
registerInputIncButton dd "axis-1-start" "axis-1-start-down" (-1.0)
let tickTextStyles = Foldable.foldr (\text textStyleArray -> Array.cons (Tup.Tuple text graphDef.tickTextStyle) textStyleArray) [] tickText
let textStyles = tickTextStyles <> [ (Tup.Tuple graphDef.axis1Label graphDef.axisTitleTextStyle)
, (Tup.Tuple graphDef.axis2Label graphDef.axisTitleTextStyle)
, (Tup.Tuple graphDef.axis3Label graphDef.axisTitleTextStyle)
]
textDimensions <- getAllTextDimensions dd svgContainer textStyles
registerInputIncButton dd "axis-2-start" "axis-2-start-up" 1.0
registerInputIncButton dd "axis-2-start" "axis-2-start-down" (-1.0)
log $ Foldable.foldr (\dim str -> str <> "\n" <> (toString dim.widthPx) <> ", " <> (toString dim.heightPx)) "" $ Map.values textDimensions
registerInputIncButton dd "axis-3-start" "axis-3-start-up" 1.0
registerInputIncButton dd "axis-3-start" "axis-3-start-down" (-1.0)
let mySVGErr = ternaryGraph 100.0 50.0 70.0 graphDef textDimensions
mySVG <- case mySVGErr of
Left error -> throw error
Right svg -> pure svg
registerInputIncButton dd "axis-title-size" "axis-title-size-up" 1.0
registerInputIncButton dd "axis-title-size" "axis-title-size-down" (-1.0)
svgDocMay <- parseSVGFromString mySVG domParser
svgDoc <- case svgDocMay of
Left error -> throw error
Right doc -> pure doc
registerInputIncButton dd "tick-label-size" "tick-label-size-up" 1.0
registerInputIncButton dd "tick-label-size" "tick-label-size-down" (-1.0)
elMay <- firstElementChild $ toParentNode svgDoc
svgNode <- case elMay of
Nothing -> throw "no child in svg doc"
Just el -> pure $ Element.toNode el
registerInputIncButton dd "ticks" "ticks-up" 1.0
registerInputIncButton dd "ticks" "ticks-down" (-1.0)
newNode <- importNode svgNode true dd
appendChild newNode svgContainer
registerInputIncButton dd "tick-size" "tick-size-up" 1.0
registerInputIncButton dd "tick-size" "tick-size-down" (-1.0)
listener <- eventListener update
inputElement <- myGetElementById dd "ticks"
addEventListener (EventType "input") listener true (Element.toEventTarget inputElement)
log "20250727T183907"
--inputMay <- getElementById "ticks" $ toNonElementParentNode dd
--inputNode <- case inputMay of
--Nothing -> throw $ "Unable to find " <> containerID
--Just e -> pure $ Element.toNode e
update

View File

@@ -1,6 +1,6 @@
module TernaryGraph where
import Prelude (discard, class Monoid, class Semigroup, Unit, ($), (<=), (<<<), (<>), (*), (+), (-), (/), (&&))
import Prelude (discard, class Monoid, class Semigroup, Unit, (==), ($), (<), (<=), (<<<), (<>), (*), (+), (-), (/), (&&))
--import Data.Array ((!!), concat, cons, mapWithIndex, range)
import Data.Array as Array
@@ -18,6 +18,13 @@ import Data.Set as Set
import Data.Tuple as Tup
import Data.Tuple.Nested (Tuple3, tuple3, get1, get2, get3)
-- A spatial value (position, size) in pixels
-- TODO: Apply this across all spatial values
newtype Pixels = Pixels Number
unpixel :: Pixels -> Number
unpixel (Pixels value) = value
type Dimension =
{ widthPx :: Number
, heightPx :: Number
@@ -42,6 +49,7 @@ type GraphDefinition =
, axis3Start :: Int
, numTicks :: Int
, tickTextStyle :: TextStyle
, tickSize :: Pixels
, axisTitleTextStyle :: TextStyle
}
@@ -101,21 +109,23 @@ svgTextID idMaybe text { x: x, y: y } angle style dimension =
Maybe.Nothing -> ""
Maybe.Just id -> "id=\"" <> id <> "\""
-- TODO: Make axis tick size a parameter
getTick :: Number -> Int -> Int -> Line
getTick scale numTicks tickI =
{start: {x: x, y: -(0.5 + 5.0 / scale)}, end: {x: x, y: y}}
getTick :: Number -> Int -> Pixels -> Int -> Line
getTick scale numTicks tickSize tickI =
{start: {x: x, y: -(0.5 + (unpixel tickSize) / scale)}, end: {x: x, y: y}}
where
x = 2.0 * (sin (pi / 3.0)) * (Int.toNumber tickI) / (Int.toNumber numTicks) - (sin (pi / 3.0))
y = if tickI <= numTicks / 2
-- For even number of ticks, the ticks don't intersect in the required pattern, so offset them
x = if (Int.rem numTicks 2) == 0
then 2.0 * (sin (pi / 3.0)) * (Int.toNumber tickI) / (Int.toNumber numTicks) - (sin (pi / 3.0))
else 2.0 * (sin (pi / 3.0)) * ((Int.toNumber tickI) + 1.0) / ((Int.toNumber numTicks) + 0.5) - (sin (pi / 3.0)) - ((9.5 / 6.0) / ((Int.toNumber numTicks) + 0.5))
y = if x < 0.0
then 1.0 + x * 1.5 / (sin (pi / 3.0))
else 1.0 - x * 1.5 / (sin (pi / 3.0))
getTicks :: Number -> Number -> Int -> Tuple3 (Array Line) (Array Line) (Array Line)
getTicks scale angle numTicks =
getTicks :: Number -> Number -> Pixels -> Int -> Tuple3 (Array Line) (Array Line) (Array Line)
getTicks scale angle tickSize numTicks =
tuple3 axis1Lines axis2Lines axis3Lines
where
foo = map (getTick scale numTicks) (Array.range 0 numTicks)
foo = map (getTick scale (numTicks - 1) tickSize) (Array.range 0 (numTicks - 1))
axis1Lines = map (rotateLine angle) foo
axis2Lines = map (rotateLine (2.0 * pi / 3.0)) axis1Lines
axis3Lines = map (rotateLine (2.0 * pi / 3.0)) axis2Lines
@@ -127,9 +137,9 @@ unfragment (XMLFragment frag) = frag
ternaryGraphSvg :: Array XMLFragment -> String
ternaryGraphSvg fragments = """<?xml version="1.0" encoding="UTF-8"?>
<svg
width="150mm"
height="120mm"
viewBox="-50 -50 200 250"
width="110mm"
height="80mm"
viewBox="-75 -50 250 220"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
@@ -159,7 +169,7 @@ tickLabelStrings def =
ternaryGraph :: Number -> Number -> Number -> GraphDefinition -> Map.Map (Tup.Tuple String TextStyle) Dimension -> Either.Either String String
ternaryGraph scale xOffset yOffset definition textDimensions = result
where
axisTickLines = getTicks scale pi definition.numTicks
axisTickLines = getTicks scale pi definition.tickSize definition.numTicks
axis1TickLines = map (transformLine scale xOffset yOffset) (get1 axisTickLines)
axis2TickLines = map (transformLine scale xOffset yOffset) (get2 axisTickLines)
axis3TickLines = map (transformLine scale xOffset yOffset) (get3 axisTickLines)
@@ -204,8 +214,13 @@ ternaryGraph scale xOffset yOffset definition textDimensions = result
axis2TickStarts = map (\line -> line.start + axis2Offset)
axis3TickStarts = map (\line -> line.start + axis3Offset)
axisTickLabels = \rotation startI -> Array.mapWithIndex (\i point ->
let text = ("E" <> (toString (Int.toNumber (i + startI))))
axisTickLabels = \rotation startI inverted -> Array.mapWithIndex (\i point ->
let index = if inverted
then startI + definition.numTicks - 1 - i
else startI + i
in
--let index = direction * (i + startI) in
let text = ("E" <> (toString (Int.toNumber index)))
angle = 0.0
labelText = case Map.lookup (Tup.Tuple text definition.tickTextStyle) textDimensions of
Maybe.Nothing -> Either.Left ("Failed to find '" <> text <> "' in dimensions map")
@@ -214,9 +229,9 @@ ternaryGraph scale xOffset yOffset definition textDimensions = result
)
axis1TickLabels :: Array (Either.Either String XMLFragment)
axis1TickLabels = axisTickLabels 0.0 definition.axis1Start $ axis1TickStarts axis1TickLines
axis2TickLabels = axisTickLabels 0.0 definition.axis2Start $ axis2TickStarts axis2TickLines
axis3TickLabels = axisTickLabels 0.0 definition.axis3Start $ axis3TickStarts axis3TickLines
axis1TickLabels = axisTickLabels 0.0 definition.axis1Start true $ axis1TickStarts axis1TickLines
axis2TickLabels = axisTickLabels 0.0 definition.axis2Start false $ axis2TickStarts axis2TickLines
axis3TickLabels = axisTickLabels 0.0 definition.axis3Start true $ axis3TickStarts axis3TickLines
labelFragmentsErr = Array.concat [axisTitlesSvg, axis1TickLabels, axis2TickLabels, axis3TickLabels]

129
style.css Normal file
View File

@@ -0,0 +1,129 @@
.avatar {
width: 2em;
margin-right: 0.2em;
}
body {
font-family: Liberation sans, sans-serif;
display: flex;
flex-direction: column;
border-style: solid;
border-width: 3px;
border-color: #f97200;
width: 80%;
max-width: 800px;
margin: auto auto;
padding-left: 1em;
padding-right: 1em;
}
#main-content {
padding: 0em 1em 1em 1em;
flex-direction: column;
max-width: 100%;
width: auto;
}
div {
width: fit-content;
display: flex;
flex-direction: row;
}
h1 {
text-align: center;
margin: 0.1em;
width: 100%;
}
h2 {
text-align: center;
margin: 0.1em;
color: #707070;
}
hr {
border: 0;
border-top: 3px solid #c0c0c0;
width: 100%;
}
table {
border-collapse: collapse;
text-align: center;
vertical-align: center;
margin: 1em;
}
table th, table td {
border-style: solid;
border-color: #c0c0c0;
border-width: 3px;
padding: 0.5em;
}
table tr:nth-child(odd) {
background-color: #fff;
}
table tr:nth-child(odd) {
background-color: #f5f5f5;
}
.button-wrapper {
height: 3em;
width: 5em;
border-style: solid;
border-color: black;
border-width: 1px;
margin: 1em;
}
.button-wrapper:hover {
border-color: #3C7FB1;
}
button {
border-width: 3px;
}
.label {
margin: 10px;
font-weight: bold;
font-size: x-large;
}
#breadcrumb {
align-items: center;
width: 100%;
font-weight: bold;
margin-bottom: 0.5em;
margin-top: 0.5em;
flex-wrap: wrap;
}
#breadcrumb a {
margin-left: 1em;
margin-right: 1em;
color: gray;
}
#breadcrumb a :hover {
text-decoration: underline;
}
.underlined {
text-decoration-line: underline;
text-decoration-style: dotted;
}
.image-container {
width: 100%;
flex-direction: column;
align-items: center;
}
#site-title {
display: flex;
align-items: center;
}

61
ternary-graph.css Normal file
View File

@@ -0,0 +1,61 @@
button {
border-width: 3px;
border-bottom-color: #B3B3B3;
border-right-color: #B3B3B3;
border-left-color: #E3E3E3;
border-top-color: #E3E3E3;
height: 2em;
}
#controls {
display: flex;
flex-direction: column;
}
.group {
display: flex;
flex-direction: column;
margin-bottom: 1em;
}
input {
border-width: 3px;
border-bottom-color: #E3E3E3;
border-right-color: #E3E3E3;
border-left-color: #B3B3B3;
border-top-color: #B3B3B3;
height: 2em;
box-sizing: border-box;
}
input[type="number"] {
-webkit-appearance: textfield;
-moz-appearance: textfield;
appearance: textfield;
width: 3em;
}
.input {
display: flex;
flex-direction: row;
margin-bottom: 0.4em;
width: auto;
}
#main-content {
flex-direction: row;
flex-wrap: wrap;
}
p {
margin: auto;
margin-right: 0.5em;
}
#svg-container {
margin: auto;
}
svg {
width: 100%
}