🏳️ SWFlag Class Reference

Composite Flag of n Rounded-Rectangle Stripes, Breathing & Spinning — SketchWaveJS Stage

Overview

SWFlag is a composite shape class — it does not extend any single shape class. Instead, it owns an array of SWRoundedRectangle stripe objects and manages them as a group. The flag is centered on an anchor SWPoint. All stripes share the same width and evenly divide the total flag height, with a configurable gap between them.

Rotation, breathing (scale), and color changes are applied to the entire group at once. The drawOnGrid() method wraps all stripe draws inside a push()/translate()/rotate()/pop() block so the whole flag pivots about the anchor point as a rigid unit.

🏳️ Why Composite?

A flag is naturally a collection of stripes, not a single primitive shape. Composing SWRoundedRectangle objects allows each stripe to carry its own color, stroke, and corner radius while the flag class handles layout math, group rotation, and group scaling — a clean application of composition over inheritance.

📐 Layout Formula

Given flagHeight, numStripes, and gap:
stripeH = (flagHeight − (n−1) × gap) / n

Stripe centers are stacked from the top: stripe 0 is highest, stripe n−1 is lowest. The anchor is at the vertical center of the whole group.

Constructor

new SWFlag(options)
new SWFlag({ anchor, flagWidth, flagHeight, numStripes, gap, cornerRadius, rotation, strokeWeight, stripeColors, fillOpacity, showCenter })

All parameters are optional and passed as a single destructured options object. The flag builds its internal stripes[] array automatically in the constructor.

ParameterTypeDefaultDescription
anchor SWPoint new SWPoint(0,0) Center of the whole flag group in user (grid) coordinates.
flagWidth number 12 Width of each stripe in user units.
flagHeight number 9 Total height from top edge of stripe 0 to bottom edge of stripe n−1, in user units.
numStripes number 6 Number of stripes.
gap number 0.25 Gap between stripes in user units.
cornerRadius number 0.35 Corner radius for all stripes in user units.
rotation number 10 Static rotation of the whole flag in degrees (CCW positive).
strokeWeight number 3 Border thickness in pixels for all stripes.
stripeColors string[] rainbow palette (6 colors) Array of hex color strings, one per stripe. Cycles if shorter than numStripes.
fillOpacity number 92 Fill alpha for all stripes, 0–100.
showCenter boolean falseIf true, draws a crosshair dot at the anchor point.
Example
// Create a 6-stripe flag centered at the grid origin
const flag = new SWFlag({
    anchor:       new SWPoint(0, 0),
    flagWidth:    12,
    flagHeight:   9,
    numStripes:   6,
    gap:          0.25,
    cornerRadius: 0.35,
    rotation:     10,
    strokeWeight: 3,
    stripeColors: ['#d98a9a','#c0395a','#e07b2a','#d4a017','#1e6b45','#5b90a8'],
    fillOpacity:  92,
});

Own Properties

These properties are set from the options object and stored directly on the instance. All are in user (grid) units unless noted.

anchor  SWPoint
Center of the entire flag group in user coordinates. All stripe positions are computed relative to this point. You can move the flag at runtime by changing anchor.x and anchor.y, then calling _updateLayout().
flagWidth  number
Width of each stripe in user units. All stripes share the same width.
flagHeight  number
Total height of the flag group in user units (top of stripe 0 to bottom of stripe n−1, including gaps).
numStripes  number
Number of stripes. Changing this after construction requires calling _buildStripes() to rebuild the stripes[] array.
gap  number
Gap between consecutive stripes in user units. Smaller values produce a more solid-looking flag; 0 gives touching stripes.
cornerRadius  number
Corner radius passed to every SWRoundedRectangle stripe in user units. Clamped automatically at draw time.
rotation  number
Static base rotation of the whole flag in degrees (CCW positive). Added to _animRotDeg at draw time.
strokeWeight  number
Border thickness in pixels applied to all stripes.
stripeColors  string[]
Array of hex color strings, one per stripe. If the array is shorter than numStripes, values cycle (i.e., stripeColors[i % stripeColors.length]). Each stripe also automatically derives a darker stroke color from its fill color.
fillOpacity  number
Fill alpha (0–100) applied to all stripes. Use setFillOpacity(alpha) to change it at runtime and update all stripes simultaneously.
showCenter  boolean
If true, draws a small crosshair circle at the anchor point to help visualize the pivot.
stripes  SWRoundedRectangle[]
The internal array of stripe objects, built during construction and rebuilt on reset(). Direct access is possible for advanced use; prefer the public API methods for color changes.

Internal Animation Trackers

These private-convention properties track the current animation state. Set them only indirectly through the animation methods.

_animRotDeg  number internal
Accumulated rotation from rotateAboutAnchor() in degrees. Added to rotation at draw time. Reset to 0 by reset().
_scaleX  number internal
Current X-axis scale factor from breathe(). Applied to flagWidth at draw time.
_scaleY  number internal
Current Y-axis scale factor from breathe(). Applied to stripeH at draw time.

Computed Properties

stripeH  getter

Height of a single stripe in user units, computed from the current flagHeight, numStripes, and gap:

stripeH = (flagHeight − (numStripes − 1) × gap) / numStripes

Always at least 0.01 user units to prevent degenerate geometry. This value is live — it automatically reflects any runtime changes to flagHeight, numStripes, or gap.

console.log(flag.stripeH); // e.g., 1.375 for defaults

Color Methods

setStripeColor(i, hex)  method
setStripeColor(i: number, hex: string): void

Changes the fill color of stripe i (0-based) to the given hex string. Automatically derives a darker stroke color using fillColor.createDarkerColor(0.65). Also updates stripeColors[i] so the palette stays in sync.

Does nothing if i is out of range.

flag.setStripeColor(0, '#ff4444'); // set top stripe to red
flag.setStripeColor(3, '#44ff88'); // set stripe 3 to green
setFillOpacity(alpha)  method
setFillOpacity(alpha: number): void

Updates fillOpacity on the instance and calls setAlphaTo(alpha) on the fill color of every stripe simultaneously. Range: 0 (transparent) to 100 (fully opaque).

flag.setFillOpacity(60); // make all stripes semi-transparent

Animation Methods

breathe(sinX, sinY, t)  method
breathe(sinX: SWSinusoid|null, sinY: SWSinusoid|null, t: number): void

Scales the flag via two independent SWSinusoid objects. Updates _scaleX and _scaleY, then calls transform() on each stripe so that individual stripe dimensions also track the scale. Call once per frame.

Pass null for either sinusoid to hold that axis at scale 1.

// In draw():
const t = millis() / 1000;
flag.breathe(sinX, sinY, t);
flag.drawOnGrid(grid);
rotateAboutAnchor(degPerSec, t)  method
rotateAboutAnchor(degPerSec: number, t: number): void

Spins the entire flag as a rigid body about anchor. Sets _animRotDeg = degPerSec × t. This is added to rotation inside drawOnGrid().

// Rotate at 30 degrees per second
flag.rotateAboutAnchor(30, millis() / 1000);
reset()  method
reset(): void

Restores all properties to their original constructor values — flagWidth, flagHeight, gap, rotation, cornerRadius, strokeWeight, anchor position, and stripeColors. Resets _animRotDeg, _scaleX, and _scaleY to their defaults, then rebuilds the entire stripes[] array from scratch.

flag.reset(); // restore everything to construction-time values

Drawing Methods

drawOnGrid(grid)  method
drawOnGrid(grid: SWGrid): void

The preferred draw method. Maps the anchor to screen coordinates, then enters a push()/translate(anchor screen position)/rotate(totalRotation)/pop() block. Inside the block, each stripe is drawn at its local pixel offset from the anchor (no individual stripe rotation — the group rotation handles everything).

Rotation = -(rotation + _animRotDeg) × π / 180 (negated because p5 uses CW-positive).

Width and height of each drawn stripe are scaled by _scaleX and _scaleY respectively, and corner radius is clamped to Math.min(cr × grid.xScale, sw/2, sh/2).

flag.drawOnGrid(grid);

Hit-Testing

stripeIndexAt(mx, my, grid)  method
stripeIndexAt(mx: number, my: number, grid: SWGrid): number

Returns the 0-based index of the stripe under screen coordinates (mx, my), or -1 if no stripe is hit.

Algorithm:

  1. Translate the mouse position into anchor-local pixel space: dx = mx − anchorScreenX, dy = my − anchorScreenY.
  2. Un-rotate by the total rotation angle (inverse rotation matrix).
  3. Test each stripe's axis-aligned bounding box in local space: |lx| ≤ halfWidth and |ly − stripeLocalPy| ≤ halfHeight.
  4. Return the first stripe index that passes both tests, or -1.

Note: the hit-test uses the rectangular bounding box of each stripe, not the rounded corners. Clicks near rounded corners may register as hits even if they are visually outside the rounded edge.

// In mousePressed():
const idx = flag.stripeIndexAt(mouseX, mouseY, grid);
if (idx !== -1) {
    console.log("Clicked stripe", idx);
    flag.setStripeColor(idx, '#ff0000');
}

Examples

Basic Flag on a Grid

// In your p5 sketch:
let grid, flag;

function setup() {
    createCanvas(600, 500);
    colorMode(HSB, 360, 100, 100, 100);
    grid = new SWGrid(new SWPoint(-14, 10), new SWPoint(14, -10));
    grid.init(width, height);

    flag = new SWFlag({
        anchor:    new SWPoint(0, 0),
        flagWidth: 12,
        flagHeight: 9,
        numStripes: 6,
    });
}

function draw() {
    background(0, 0, 95);
    grid.drawOnScreen();
    flag.drawOnGrid(grid);
}

Breathing

let sinX, sinY, isBreathing = false, breathStart;

function setup() {
    // ... grid and flag setup as above ...
    sinX = new SWSinusoid(1.0, 1.5, 1.4, 0.7, 0); // period, amp, max, min, phase
    sinY = new SWSinusoid(1.2, 1.5, 1.4, 0.7, 0.3);
}

function draw() {
    background(0, 0, 95);
    grid.drawOnScreen();

    if (isBreathing) {
        const t = (millis() - breathStart) / 1000;
        flag.breathe(sinX, sinY, t);
    }
    flag.drawOnGrid(grid);
}

function keyPressed() {
    if (key === 'b') {
        isBreathing = !isBreathing;
        if (isBreathing) breathStart = millis();
        else flag.reset();
    }
}

Spinning

let isSpinning = false, spinStart;
const SPIN_RATE = 45; // degrees per second

function draw() {
    background(0, 0, 95);
    grid.drawOnScreen();

    if (isSpinning) {
        const t = (millis() - spinStart) / 1000;
        flag.rotateAboutAnchor(SPIN_RATE, t);
    }
    flag.drawOnGrid(grid);
}

Click to Recolor a Stripe

function mousePressed() {
    if (mouseX < 0 || mouseX > width || mouseY < 0 || mouseY > height) return;
    const idx = flag.stripeIndexAt(mouseX, mouseY, grid);
    if (idx !== -1) {
        // Pick a random bright hue
        const h = floor(random(360));
        const hex = '#' + hex(color(h, 80, 90), 6).toUpperCase().substring(2);
        flag.setStripeColor(idx, hex);
    }
}

Custom Palette

const flag = new SWFlag({
    stripeColors: ['#003087', '#ffffff', '#CE1126'], // France-inspired
    numStripes: 3,
    flagWidth: 12,
    flagHeight: 8,
});

Tips & Best Practices

📌 User-Unit Parameters
All size parameters — flagWidth, flagHeight, gap, cornerRadius — are in grid user units, not pixels. This means the flag scales correctly when the canvas is resized or the grid zoom changes.
📌 stripeH is a Getter, Not a Property
flag.stripeH is computed on every read. Do not try to assign to it. To change stripe height, change flagHeight, numStripes, or gap, then call _updateLayout() (or reset() + reconstruct).
📌 reset() Rebuilds Everything
reset() calls _buildStripes() internally, which creates fresh SWRoundedRectangle objects. Any runtime color changes made by setStripeColor() will be lost — reset restores the original stripeColors array from construction time.
📌 Hit-Test Uses Bounding Rectangles
stripeIndexAt() tests axis-aligned bounding boxes in un-rotated anchor-local space. Pixels inside the rounded corners of a stripe but outside the rectangular box still count as hits. This is an intentional simplification — exact rounded-corner hit-testing would require more complex math.
📌 Breathing + Spinning Together
You can call both breathe() and rotateAboutAnchor() in the same frame. They operate independently — breathing adjusts _scaleX/_scaleY, spinning adjusts _animRotDeg, and drawOnGrid() applies both.
📌 Color Cycling
If you pass fewer colors than numStripes, the colors cycle via i % stripeColors.length. For example, 3 colors on 6 stripes gives an ABABAB pattern. This makes it easy to create alternating two-tone flags.
📌 Canvas-Bounds Guard in mousePressed
p5's global mousePressed() fires for all page clicks — including on UI controls. Always guard with if (mouseX < 0 || mouseX > width || mouseY < 0 || mouseY > height) return; to avoid spurious stripe selections when the user clicks outside the canvas.

Source Code

Complete source for swFlag.js:

/*
File:    swFlag.js
Date:    2026-04-22
Author:  klp + GitHub Copilot
App:     SketchWaveTNT2026-04-21-Stg8
Purpose: SWFlag — a flag-shaped composite of n SWRoundedRectangle stripes.

=== Layout ===
  The flag is centered on `anchor` (an SWPoint in user/grid coords).
  n horizontal stripes of equal width and height are stacked vertically with
  a uniform gap between them.

  totalFlagHeight = n * stripeH + (n-1) * gap
  totalFlagWidth  = stripeW

  Stripes are numbered 0 (top) → n-1 (bottom) and stored in this.stripes[].

=== Design Parameters (all in user/grid units) ===
  anchor       — SWPoint: center of the whole flag group
  flagWidth    — full width of each stripe
  flagHeight   — total height from top of stripe 0 to bottom of stripe n-1
  numStripes   — number of stripes (default 6)
  gap          — gap between stripes (default 0.25)
  cornerRadius — corner radius for every stripe (default 0.35)
  rotation     — static rotation of the whole flag (degrees CCW, default 10)
  strokeWeight — border thickness for every stripe (default 3)
  stripeColors — Array of hex strings, one per stripe (cycles if too short)
  fillOpacity  — alpha for fill (0-100, default 92)

=== Animation ===
  breathe(sinX, sinY, t)     — scale all stripes + re-layout
  rotateAboutAnchor(deg/s,t) — spin the whole flag; sets _animRotDeg
  reset()                    — restore all original values

=== Drawing ===
  drawOnGrid(grid)   — preferred (user-coord) draw

=== Hit-testing ===
  stripeIndexAt(mx, my, grid) — returns index of stripe under mouse, or -1

=== Dependencies ===
  p5.js, SWColor, SWPoint, SWRoundedRectangle, SWGrid
*/

console.log("[swFlag.js] SWFlag class loaded.");

class SWFlag {

    constructor({
        anchor       = new SWPoint(0, 0),
        flagWidth    = 12,
        flagHeight   = 9,
        numStripes   = 6,
        gap          = 0.25,
        cornerRadius = 0.35,
        rotation     = 10,
        strokeWeight = 3,
        stripeColors = ['#d98a9a','#c0395a','#e07b2a','#d4a017','#1e6b45','#5b90a8'],
        fillOpacity  = 92,
        showCenter   = false,
    } = {}) {
        this.anchor       = anchor;
        this.flagWidth    = flagWidth;
        this.flagHeight   = flagHeight;
        this.numStripes   = numStripes;
        this.gap          = gap;
        this.cornerRadius = cornerRadius;
        this.rotation     = rotation;
        this.strokeWeight = strokeWeight;
        this.stripeColors = stripeColors;
        this.fillOpacity  = fillOpacity;
        this.showCenter   = showCenter;

        // Animation state
        this._animRotDeg = 0;
        this._scaleX     = 1;
        this._scaleY     = 1;

        // Store originals for reset
        this._origFlagWidth    = flagWidth;
        this._origFlagHeight   = flagHeight;
        this._origGap          = gap;
        this._origRotation     = rotation;
        this._origCornerRadius = cornerRadius;
        this._origStrokeWeight = strokeWeight;
        this._origAnchorX      = anchor.x;
        this._origAnchorY      = anchor.y;
        this._origStripeColors = stripeColors.slice();

        this.stripes = [];
        this._buildStripes();
    }

    get stripeH() {
        const n = this.numStripes;
        return Math.max(0.01, (this.flagHeight - (n - 1) * this.gap) / n);
    }

    _localCenterY(i) {
        const topY = (this.flagHeight / 2) - (this.stripeH / 2);
        return topY - i * (this.stripeH + this.gap);
    }

    _buildStripes() {
        this.stripes = [];
        for (let i = 0; i < this.numStripes; i++) {
            this.stripes.push(this._makeStripe(i));
        }
    }

    _makeStripe(i) {
        const cx = this.anchor.x;
        const cy = this.anchor.y + this._localCenterY(i);
        const hexColor = this.stripeColors[i % this.stripeColors.length];
        const fillCol  = SWColor.fromHex(hexColor, `stripe${i}Fill`);
        fillCol.setAlphaTo(this.fillOpacity);
        const strokeCol = fillCol.createDarkerColor(0.65);
        strokeCol.setAlphaTo(100);
        return new SWRoundedRectangle(new SWPoint(cx, cy), this.flagWidth, this.stripeH, fillCol, this.cornerRadius, {
            strokeColor:  strokeCol,
            strokeWeight: this.strokeWeight,
            showCenter:   false,
            rotation:     0,
        });
    }

    setStripeColor(i, hex) {
        if (i < 0 || i >= this.stripes.length) return;
        this.stripeColors[i % this.stripeColors.length] = hex;
        const fillCol = SWColor.fromHex(hex, `stripe${i}Fill`);
        fillCol.setAlphaTo(this.fillOpacity);
        const strokeCol = fillCol.createDarkerColor(0.65);
        strokeCol.setAlphaTo(100);
        this.stripes[i].fillColor   = fillCol;
        this.stripes[i].strokeColor = strokeCol;
    }

    setFillOpacity(alpha) {
        this.fillOpacity = alpha;
        for (const s of this.stripes) {
            if (s.fillColor) s.fillColor.setAlphaTo(alpha);
        }
    }

    breathe(sinX, sinY, t) {
        const minScale = 0.1;
        this._scaleX = sinX ? Math.max(minScale, sinX.getValue(t)) : 1;
        this._scaleY = sinY ? Math.max(minScale, sinY.getValue(t)) : 1;
        for (const s of this.stripes) {
            s.transform({ sinusoidX: sinX, sinusoidY: sinY, t });
        }
    }

    rotateAboutAnchor(degPerSec, t) {
        this._animRotDeg = degPerSec * t;
    }

    reset() {
        this.flagWidth    = this._origFlagWidth;
        this.flagHeight   = this._origFlagHeight;
        this.gap          = this._origGap;
        this.rotation     = this._origRotation;
        this.cornerRadius = this._origCornerRadius;
        this.strokeWeight = this._origStrokeWeight;
        this.anchor.x     = this._origAnchorX;
        this.anchor.y     = this._origAnchorY;
        this.stripeColors = this._origStripeColors.slice();
        this._animRotDeg  = 0;
        this._scaleX      = 1;
        this._scaleY      = 1;
        this._buildStripes();
    }

    drawOnGrid(grid) {
        const s = grid.userToScreen(this.anchor.x, this.anchor.y);
        const totalDeg = this.rotation + this._animRotDeg;
        const rotRad   = -totalDeg * Math.PI / 180;

        push();
        translate(s.x, s.y);
        rotate(rotRad);
        for (let i = 0; i < this.stripes.length; i++) {
            const stripe = this.stripes[i];
            const localCY = this._localCenterY(i);
            const sw = this.flagWidth * this._scaleX * grid.xScale;
            const sh = this.stripeH   * this._scaleY * grid.yScale;
            const cr = Math.min(stripe.cornerRadius * grid.xScale, sw / 2, sh / 2);
            const px = 0;
            const py = -localCY * grid.yScale;

            if (stripe.fillColor && stripe.fillColor.col)     { fill(stripe.fillColor.col); }   else { noFill(); }
            if (stripe.strokeColor && stripe.strokeColor.col) { stroke(stripe.strokeColor.col); strokeWeight(stripe.strokeWeight); } else { noStroke(); }

            rectMode(CENTER);
            rect(px, py, sw, sh, cr);
        }
        rectMode(CORNER);
        noStroke(); noFill();
        pop();

        if (this.showCenter) {
            const as = grid.userToScreen(this.anchor.x, this.anchor.y);
            const r  = 8;
            push();
            translate(as.x, as.y);
            strokeWeight(2); stroke(0, 0, 15, 100); fill(0, 0, 100, 85);
            ellipse(0, 0, r * 2, r * 2);
            stroke(0, 0, 15, 100); strokeWeight(1.5);
            line(-(r-2), 0, r-2, 0); line(0, -(r-2), 0, r-2);
            noStroke(); noFill();
            pop();
        }
    }

    stripeIndexAt(mx, my, grid) {
        const as = grid.userToScreen(this.anchor.x, this.anchor.y);
        const dx = mx - as.x;
        const dy = my - as.y;
        const totalDeg = this.rotation + this._animRotDeg;
        const rotRad   = -totalDeg * Math.PI / 180;
        const cosA =  Math.cos(rotRad);
        const sinA =  Math.sin(rotRad);
        const lx   =  dx * cosA + dy * sinA;
        const ly   = -dx * sinA + dy * cosA;
        for (let i = 0; i < this.stripes.length; i++) {
            const sw  = (this.flagWidth * this._scaleX * grid.xScale) / 2;
            const sh  = (this.stripeH   * this._scaleY * grid.yScale) / 2;
            const py  = -this._localCenterY(i) * grid.yScale;
            if (Math.abs(lx) <= sw && Math.abs(ly - py) <= sh) return i;
        }
        return -1;
    }

    toString() {
        return `SWFlag(anchor:(${this.anchor.x.toFixed(2)},${this.anchor.y.toFixed(2)}), ` +
               `${this.numStripes} stripes, flagW:${this.flagWidth.toFixed(2)}, ` +
               `flagH:${this.flagHeight.toFixed(2)}, gap:${this.gap.toFixed(2)}, ` +
               `rot:${this.rotation.toFixed(1)}\u00b0)`;
    }

}//end class SWFlag