Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export default ts.config(
...globals.browser,
...globals.node
}
},
rules: {
"@typescript-eslint/no-unused-vars": "off"
}
},
{
Expand Down
591 changes: 519 additions & 72 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/eslint": "^9.6.1",
"@types/node": "^24.0.1",
"@typescript-eslint/eslint-plugin": "^8.22.0",
"@typescript-eslint/parser": "^8.22.0",
"embla-carousel-svelte": "^8.5.2",
Expand All @@ -46,7 +47,6 @@
"svelte-check": "^4.1.4",
"tailwindcss": "^4.0.0",
"tslib": "^2.4.1",
"typescript": "^5.5.0",
"typescript-eslint": "^8.22.0",
"vite": "^5.4.4"
},
Expand Down
149 changes: 149 additions & 0 deletions src/lib/analysis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { Almanac } from "./almanac"

// find random shortest play sequence and word count
export function getRandomShortestPlay({ countLetters = false }: { countLetters?: boolean } = {}): {
count: number
sequence: number[]
} {
const almanacDoc = Almanac.getDom()
const segmentDivs = Array.from(almanacDoc.querySelectorAll('div[type="segment"]'))

const shortestPlaySequence: number[] = []
let shortestPlayLength = 0
const regEx = countLetters ? /\p{L}/gu : /\p{L}+(?:['’]\p{L}+)*/gu
// Regex explanation:
// \p{L} => matches any kind of letter from any language (Unicode letter)
// + => matches one or more letters (i.e., a whole word)
// (?:['’]\p{L}+)* => allows one or more apostrophes (straight ' or curly ’) within a word
// g flag => global: find all matches, not just the first one
// u flag => unicode: enables full unicode matching (necessary for \p{} to work)

for (let roll = 1; roll <= 200; roll++) {
let minLength = Infinity
let shortestPips: number[] = []

for (let pips = 1; pips <= 6; pips++) {
const segmentId = Almanac.getSegmentId(roll, pips)
const segmentDiv = segmentDivs[segmentId - 1]
const divText = segmentDiv.textContent || ""
const count = (divText.match(regEx) || []).length // count is word count or letter count depending on countLetters
if (count < minLength) {
minLength = count
shortestPips = [pips]
} else if (count === minLength) {
// track multiple pips candidates of one roll that result in the same shortest word count
shortestPips.push(pips)
}
}

// console.log("Roll", roll, ":", shortestPips) // for debugging

shortestPlayLength += minLength
// randomly pick one pips candidate
shortestPlaySequence[roll - 1] = shortestPips[Math.floor(Math.random() * shortestPips.length)]
}

return {
count: shortestPlayLength,
sequence: shortestPlaySequence
}
}

// find random longest play sequence and word count
export function getRandomLongestPlay({ countLetters = false }: { countLetters?: boolean } = {}): {
count: number
sequence: number[]
} {
const almanacDoc = Almanac.getDom()
const segmentDivs = Array.from(almanacDoc.querySelectorAll('div[type="segment"]'))

const longestPlaySequence: number[] = []
let longestPlayLength = 0
const regEx = countLetters ? /\p{L}/gu : /\p{L}+(?:['’]\p{L}+)*/gu
// Regex explanation:
// \p{L} => matches any kind of letter from any language (Unicode letter)
// + => matches one or more letters (i.e., a whole word)
// (?:['’]\p{L}+)* => allows one or more apostrophes (straight ' or curly ’) within a word
// g flag => global: find all matches, not just the first one
// u flag => unicode: enables full unicode matching (necessary for \p{} to work)

for (let roll = 1; roll <= 200; roll++) {
let maxLength = -Infinity
let longestPips: number[] = []

for (let pips = 1; pips <= 6; pips++) {
const segmentId = Almanac.getSegmentId(roll, pips)
const segmentDiv = segmentDivs[segmentId - 1]
const divText = segmentDiv.textContent || ""
const count = (divText.match(regEx) || []).length
if (count > maxLength) {
maxLength = count
longestPips = [pips]
} else if (count === maxLength) {
// track multiple pips candidates of one roll that result in the same longest word count
longestPips.push(pips)
}
}

// console.log("Roll", roll, ":", longestPips) // for debugging

longestPlayLength += maxLength
// randomly pick one pips candidate
longestPlaySequence[roll - 1] = longestPips[Math.floor(Math.random() * longestPips.length)]
}

return {
count: longestPlayLength,
sequence: longestPlaySequence
}
}

// function to print both results
export function getRandomLongestAndShortestPlay({
countLetters = false
}: { countLetters?: boolean } = {}): void {
const shortest = getRandomShortestPlay({ countLetters: countLetters })
const longest = getRandomLongestPlay({ countLetters: countLetters })

let result = {}

if (countLetters) {
result = {
shortestPlay: {
letterCount: shortest.count,
sequence: shortest.sequence
},
longestPlay: {
letterCount: longest.count,
sequence: longest.sequence
}
}
} else {
result = {
shortestPlay: {
wordCount: shortest.count,
sequence: shortest.sequence
},
longestPlay: {
wordCount: longest.count,
sequence: longest.sequence
}
}
}

console.log(JSON.stringify(result, null, 2))
}

export function generateAllSamePipsSequence({ pips = 1 }: { pips?: number } = {}): {
sequence: number[]
} {
return {
sequence: new Array(200).fill(pips)
}
}

// NOTES
// Ohne List glückt’s keiner Liebe. --> 5 Wörter ✔
// shortest/longest play: Buchstaben zählen ✔
// nur 1,2,3 etc. ✔
// 100 random Stücke für Dracor
5 changes: 4 additions & 1 deletion src/lib/components/Dice3D.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import { locale } from "$lib/stores/locale"

let {
isRolling = $bindable()
isRolling = $bindable(),
playMode = $bindable()
}: {
isRolling?: boolean
playMode: string // to switch between: generate random with dice or random shortest or longest play
} = $props()

let buttonLabel = $derived($locale === "de" ? "Würfeln" : "Roll the dice")
Expand Down Expand Up @@ -110,6 +112,7 @@
if (isRolling) return // Prevent multiple clicks during animation

isRolling = true
playMode = "dice"
attractMode = false

// Get a different random face
Expand Down
1 change: 0 additions & 1 deletion src/lib/components/ShareButton.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
let sharingUrl = $derived(
window.location.origin + window.location.pathname + "#" + sequence.join("")
)

// function to copy url including sequence to clipboard
async function copyUrlToClipboard(event: MouseEvent) {
try {
Expand Down
150 changes: 150 additions & 0 deletions src/lib/tei.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { Almanac } from "./almanac"

const TEINS = "http://www.tei-c.org/ns/1.0"

function generateIdFromSequence(sequence: number[]): string {
return sequence.join("-") // this is a temporary solution
}

async function fetchTemplate(): Promise<Document> {
const res = await fetch("/play-template.xml")
const xmlText = await res.text()
const parser = new DOMParser()
return parser.parseFromString(xmlText, "text/xml")
}

function getSegment(almanacDoc: Document, roll: number, pips: number): Element | null {
const segmentId = Almanac.getSegmentId(roll, pips).toString()
const segment = almanacDoc.querySelector(`div[type="segment"][n="${segmentId}"]`)
return segment
}

// in the almanacDoc, find divs corresponding to current segment id described by roll number and pips
function collectSegments(sequence: number[], almanacDoc: Document): DocumentFragment {
const segments = document.createDocumentFragment()
const sceneStartRolls = [3, 4, 13, 18, 27, 28, 29, 57, 61, 96, 112, 114, 131, 145, 170, 184, 191]
let currentSceneDiv: Element | null = null

sequence.forEach((pips, i) => {
const roll = i + 1
const segment = getSegment(almanacDoc, roll, pips)
const sceneIndex = sceneStartRolls.indexOf(roll) // if roll number is in sceneStartRolls then it marks a scene start
const sceneNumber = sceneIndex >= 0 ? sceneIndex + 1 : null // if sceneIndex is -1 then current roll is not a scene start and scenenNumber is null

if (segment) {
if (sceneNumber != null) {
if (currentSceneDiv) segments.appendChild(currentSceneDiv) // finish previous scene
currentSceneDiv = document.createElementNS("http://www.tei-c.org/ns/1.0", "div") // start new scene
currentSceneDiv.setAttribute("type", "scene")
currentSceneDiv.setAttribute("n", sceneNumber.toString())
}
// append only the children of the div, not the outer <div type=segment> tag
Array.from(segment.childNodes).forEach((child) => {
if (currentSceneDiv) {
currentSceneDiv.appendChild(child.cloneNode(true))
} else {
// if no current scene yet, append directly
segments.appendChild(child.cloneNode(true))
}
})
}
})
// append the last scene
if (currentSceneDiv) {
segments.appendChild(currentSceneDiv)
}
return segments
}

export async function createTEIDoc(
sequence: number[]
): Promise<{ doc: string; timestamp: string }> {
const almanacDoc = Almanac.getDom() // parse almanac text XML into dom document
const templateDoc = await fetchTemplate() // get xml file template as dom document

const segments = collectSegments(sequence, almanacDoc)

// set titles in tei header and front page
const mainTitle = segments.children[0].textContent
const subTitle = segments.children[1].textContent
templateDoc.querySelector('teiHeader titleStmt > title[type="main"]')!.textContent = mainTitle
templateDoc.querySelector('teiHeader titleStmt > title[type="sub"]')!.textContent = subTitle
templateDoc.querySelector('front > titlePage > docTitle > titlePart[type="main"]')!.textContent =
mainTitle
templateDoc.querySelector('front > titlePage > docTitle > titlePart[type="sub"]')!.textContent =
subTitle

// add url to generated play
templateDoc.querySelector(
'teiHeader sourceDesc > bibl[type="edition"] > edition > idno'
)!.textContent = sequence.join("").toString()

// set sequence as edition in source description
templateDoc.querySelector(
'teiHeader sourceDesc > bibl[type="digitalSource"] > idno'
)!.textContent = "https://temporal-communities.github.io/999/" + sequence.join("").toString()

// add timestamp
const date = new Date()
const timestamp = date.toISOString() // keep full ISO 8601 string as enforced by oxygen
templateDoc
.querySelector("teiHeader revisionDesc > listChange > change")!
.setAttribute("when", timestamp)

// add titles, cast list and set description to front page and remove from segments
const front = templateDoc.querySelector("front")
for (let i = 0; i < 4 && segments.children[i]; i++) {
front?.appendChild(segments.children[i])
segments.removeChild(segments.children[i])
}

// append segments wrapped in scene divs to template's main section
const mainDiv = templateDoc.querySelector('body > div[type="main"]')
mainDiv?.appendChild(segments)

// convert DOM to XML string
const serializer = new XMLSerializer()

return { doc: serializer.serializeToString(templateDoc), timestamp: timestamp }
}

export async function downloadTEIDoc(sequence: number[]) {
const result = await createTEIDoc(sequence)
const xmlString = result.doc
const timestamp = result.timestamp
// create a downloadable file and temporary URL pointing to the file
const file = new File([xmlString], `play-${timestamp}.xml`, { type: "application/xml" })
const url = URL.createObjectURL(file)
const a = document.createElement("a")
a.href = url
a.download = file.name
a.click()
// window.open(url, "_blank") // open file in new window
setTimeout(() => URL.revokeObjectURL(url), 10000)
}

// NOTES
// improve and complete download function
// wann endet letzte szene? bevor der vorhang fällt oder danach? oder direkt vor Ende des Vorspiels?

// 1. Wurf: Titel + Untertitel
// 2. Wurf: Personen
// 3. Wurf: Szene 1
// 4. Wurf: Szene 2
// 13. Wurf: Szene 3 // WENN 2 GEWÜRFELT WIRD, FEHLT HEADER FÜR SZENE 3
// 18. Wurf: Szene 4
// 27. Wurf: Szene 5
// 28. Wurf: Szene 6
// 29. Wurf: Szene 7
// 57. Wurf: Szene 8
// 61. Wurf: Szene 9
// 96. Wurf: Szene 10
// 112. Wurf: Szene 11
// 114. Wurf: Szene 12
// 131. Wurf: Szene 13
// 145. Wurf: Szene 14 // WENN 2 ODER 6 GEWÜRFELT WIRD, FEHLT HEADER FÜR SZENE 14
// 170. Wurf: Szene 15
// 184. Wurf: Szene 16
// 191. Wurf: Szene 17 (Letzter Auftritt)
// 199. Wurf: (Der Vorhang fällt.)
// 200. Wurf: Ende des Vorspiels.
Loading