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.
SWRoundedRectangle, SWColor, SWPoint, SWGrid, p5.jsSWFlag does not extend SWRoundedRectangle — it owns an array of them.
Constructor
new SWFlag(options)
All parameters are optional and passed as a single destructured options object. The flag builds its internal stripes[] array automatically in the constructor.
| Parameter | Type | Default | Description |
|---|---|---|---|
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 | false | If 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 SWPointanchor.x and anchor.y, then calling _updateLayout().flagWidth numberflagHeight numbernumStripes number_buildStripes() to rebuild the stripes[] array.gap numbercornerRadius numberSWRoundedRectangle stripe in user units. Clamped automatically at draw time.rotation number_animRotDeg at draw time.strokeWeight numberstripeColors string[]numStripes, values cycle (i.e., stripeColors[i % stripeColors.length]). Each stripe also automatically derives a darker stroke color from its fill color.fillOpacity numbersetFillOpacity(alpha) to change it at runtime and update all stripes simultaneously.showCenter booleantrue, draws a small crosshair circle at the anchor point to help visualize the pivot.stripes SWRoundedRectangle[]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 internalrotateAboutAnchor() in degrees. Added to rotation at draw time. Reset to 0 by reset()._scaleX number internalbreathe(). Applied to flagWidth at draw time._scaleY number internalbreathe(). Applied to stripeH at draw time.Computed Properties
stripeH getterHeight of a single stripe in user units, computed from the current flagHeight, numStripes, and gap:
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
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
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
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
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
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
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
Returns the 0-based index of the stripe under screen coordinates (mx, my), or -1 if no stripe is hit.
Algorithm:
- Translate the mouse position into anchor-local pixel space:
dx = mx − anchorScreenX,dy = my − anchorScreenY. - Un-rotate by the total rotation angle (inverse rotation matrix).
- Test each stripe's axis-aligned bounding box in local space:
|lx| ≤ halfWidthand|ly − stripeLocalPy| ≤ halfHeight. - 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
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.
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() 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.
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.
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.
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.
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