# core/validators/loop.validator.ts

> 126 lines of code and 40 definitions.

Tree: Bane's Lab Social Share
Language: typescript
Layer: processing
Canonical: https://banes-lab.com/anatomy/social-share#file-social-share-core-validators-loop-validator-ts
Source text: https://banes-lab.com/source/social-share/core/validators/loop.validator.ts.txt

Listed in [core/validators](https://banes-lab.com/api/source/social-share/core/validators.md), after [core/validators/card.validator.ts](https://banes-lab.com/source/social-share/core/validators/card.validator.ts.md) and before [core/validators/page.validator.ts](https://banes-lab.com/source/social-share/core/validators/page.validator.ts.md).

## Definitions

- `isDigit` (lexical_declaration, line 11)
- `signatureOf` (lexical_declaration, line 77)
- `startsFraction` (lexical_declaration, line 15)
- `startsNumber` (lexical_declaration, line 19)
- `continuesNumber` (lexical_declaration, line 26)
- `textTokens` (lexical_declaration, line 45, exported)
- `sameText` (lexical_declaration, line 71, exported)
- `sameToken` (lexical_declaration, line 64)
- `turnsAtSeam` (lexical_declaration, line 103)
- `loopFindings` (lexical_declaration, line 129, exported)
- `seamBreaks` (lexical_declaration, line 82)
- `numbersOf` (lexical_declaration, line 93)
- `numberEnd` (lexical_declaration, line 37)
- `velocityBreaks` (lexical_declaration, line 111)
- `DIGITS` (lexical_declaration, line 6)
- `SIGNS` (lexical_declaration, line 7)
- `POINT` (lexical_declaration, line 8)
- `EXPONENTS` (lexical_declaration, line 9)
- `letter` (lexical_declaration, line 27)
- `tokens` (lexical_declaration, line 46, exported)
- `word` (lexical_declaration, line 47, exported)
- `at` (lexical_declaration, line 48, exported)
- `a` (lexical_declaration, line 72, exported)
- `b` (lexical_declaration, line 73, exported)
- `placement }` (lexical_declaration, line 78)
- `start` (lexical_declaration, line 83)
- `end` (lexical_declaration, line 84, exported)
- `other` (lexical_declaration, line 87)
- `stepsOf` (lexical_declaration, line 99)
- `steps` (lexical_declaration, line 104)
- `largest` (lexical_declaration, line 105)
- `leaving` (lexical_declaration, line 106)
- `entering` (lexical_declaration, line 107)
- `frames` (lexical_declaration, line 112)
- `[first = []]` (lexical_declaration, line 116)
- `series` (lexical_declaration, line 119)
- `width` (lexical_declaration, line 120)
- `own` (lexical_declaration, line 133, exported)
- `breaks` (lexical_declaration, line 134, exported)
- `readsTime` (lexical_declaration, line 137, exported)

## Contained in

- [core/validators](https://banes-lab.com/anatomy/social-share/folder-social-share-core-validators.md)

## Used by

- [core/validators/card.validator.ts](https://banes-lab.com/source/social-share/core/validators/card.validator.ts.md)

## Linked from

- [core/validators](https://banes-lab.com/anatomy/social-share/folder-social-share-core-validators.md)

## Source

```typescript
import type { CardSpec, Profile, ResolvedLayer } from "#types/card.types";
import { LIST_SEPARATOR, LOOP_SEAM, SHADER_READS_TIME, SUBJECT_SEPARATOR } from "#configuration/strings/card.strings";
import { SEAM_TOLERANCE, SEAM_VELOCITY_RATIO, SHADER_TIME_FIELD } from "#configuration/constants/card.constants";
import { resolveCard } from "#core/evaluators/card.evaluator";

const DIGITS: ReadonlySet<string> = new Set(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]);
const SIGNS: ReadonlySet<string> = new Set(["-", "+"]);
const POINT = ".";
const EXPONENTS: ReadonlySet<string> = new Set(["e", "E"]);

const isDigit = function isDigit(text: string, at: number): boolean {
    return DIGITS.has(text.charAt(at));
};

const startsFraction = function startsFraction(text: string, at: number): boolean {
    return text.charAt(at) === POINT && isDigit(text, at + 1);
};

const startsNumber = function startsNumber(text: string, at: number): boolean {
    if (isDigit(text, at) || startsFraction(text, at)) {
        return true;
    }
    return SIGNS.has(text.charAt(at)) && (isDigit(text, at + 1) || startsFraction(text, at + 1));
};

const continuesNumber = function continuesNumber(text: string, at: number): boolean {
    const letter = text.charAt(at);
    if (isDigit(text, at) || letter === POINT) {
        return true;
    }
    if (EXPONENTS.has(letter)) {
        return isDigit(text, at + 1) || SIGNS.has(text.charAt(at + 1));
    }
    return SIGNS.has(letter) && EXPONENTS.has(text.charAt(at - 1));
};

const numberEnd = function numberEnd(text: string, from: number): number {
    let at = from + 1;
    while (at < text.length && continuesNumber(text, at)) {
        at += 1;
    }
    return at;
};

export const textTokens = function textTokens(text: string): readonly (number | string)[] {
    const tokens: (number | string)[] = [];
    let word = "";
    let at = 0;
    while (at < text.length) {
        if (startsNumber(text, at)) {
            const end = numberEnd(text, at);
            tokens.push(word, Number(text.slice(at, end)));
            word = "";
            at = end;
        } else {
            word += text.charAt(at);
            at += 1;
        }
    }
    tokens.push(word);
    return tokens;
};

const sameToken = function sameToken(left: number | string | undefined, right: number | string | undefined): boolean {
    if (typeof left === "number" && typeof right === "number") {
        return Math.abs(left - right) <= SEAM_TOLERANCE;
    }
    return left === right;
};

export const sameText = function sameText(left: string, right: string): boolean {
    const a = textTokens(left);
    const b = textTokens(right);
    return a.length === b.length && a.every((token, index) => sameToken(token, b[index]));
};

const signatureOf = function signatureOf(layer: ResolvedLayer): string {
    const { anchor, ...placement } = layer.placement;
    return JSON.stringify([anchor, placement, layer.opacity, layer.style, layer.filter, layer.content, layer.uniforms]);
};

const seamBreaks = function seamBreaks(spec: CardSpec, profile: Profile): readonly string[] {
    const start = resolveCard(spec, profile, 0).layers;
    const end = resolveCard(spec, profile, spec.timeline.frames).layers;
    return start
        .filter((layer, index) => {
            const other = end[index];
            return other === undefined || !sameText(signatureOf(layer), signatureOf(other));
        })
        .map((layer) => layer.id);
};

const numbersOf = function numbersOf(layer: ResolvedLayer | undefined): readonly number[] {
    return layer === undefined
        ? []
        : textTokens(signatureOf(layer)).filter((token): token is number => typeof token === "number");
};

const stepsOf = function stepsOf(series: readonly (readonly number[])[], slot: number): readonly number[] {
    return series.slice(1).map((values, index) => (values[slot] ?? 0) - (series[index]?.[slot] ?? 0));
};

const turnsAtSeam = function turnsAtSeam(series: readonly (readonly number[])[], slot: number): boolean {
    const steps = stepsOf(series, slot);
    const largest = Math.max(...steps.map((step) => Math.abs(step)));
    const leaving = steps[0] ?? 0;
    const entering = steps.at(-1) ?? 0;
    return Math.abs(entering - leaving) > SEAM_VELOCITY_RATIO * largest + SEAM_TOLERANCE;
};

const velocityBreaks = function velocityBreaks(spec: CardSpec, profile: Profile): readonly string[] {
    const frames = Array.from(
        { length: spec.timeline.frames + 1 },
        (_, frame) => resolveCard(spec, profile, frame).layers,
    );
    const [first = []] = frames;
    return first
        .filter((_, index) => {
            const series = frames.map((layers) => numbersOf(layers[index]));
            const width = series[0]?.length ?? 0;
            if (series.some((values) => values.length !== width)) {
                return false;
            }
            return Array.from({ length: width }, (__, slot) => slot).some((slot) => turnsAtSeam(series, slot));
        })
        .map((layer) => layer.id);
};

export const loopFindings = function loopFindings(spec: CardSpec, profiles: readonly Profile[]): readonly string[] {
    if (!spec.timeline.loop || spec.timeline.frames <= 1) {
        return [];
    }
    const own = profiles.filter((profile) => spec.profiles.includes(profile.id));
    const breaks = [
        ...new Set(own.flatMap((profile) => [...seamBreaks(spec, profile), ...velocityBreaks(spec, profile)])),
    ];
    const readsTime = spec.layers.some((layer) => layer.kind === "shader" && layer.shader.includes(SHADER_TIME_FIELD));
    return [
        ...(breaks.length === 0 ? [] : [LOOP_SEAM + SUBJECT_SEPARATOR + breaks.join(LIST_SEPARATOR)]),
        ...(readsTime ? [SHADER_READS_TIME] : []),
    ];
};
```
