<u7n-polygon polygon-segments="round 15, 0 0, 100 0, 100 100, 80 100, sharp 100 140, 30 100, 0 100" polygon-resize="20 20" class="bubble">
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Blanditiis culpa autem non, in corporis fugiat adipisci natus sunt iste atque id earum numquam architecto, excepturi obcaecati velit provident veniam iure?</p>
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Voluptate, assumenda quisquam similique reiciendis reprehenderit porro et maxime impedit. Quaerat, aspernatur harum. Accusantium reiciendis, quo provident illum assumenda tempora eveniet laborum.</p>
</u7n-polygon>
body {
background: linear-gradient(-45deg, #008, #800);
min-height: 100vh;
margin: 0;
display: grid;
align-items: start;
}
.bubble {
margin: 10px;
background: linear-gradient(-10deg, red, yellow);
padding: 20px 20px 60px;
clip-path: path(var(--u7n-polygon-path));
}
:root { font-family: system-ui; }
p { margin-block: 0; }
p + p { margin-block-start: 0.5em; }
// https://gist.github.com/ivan-u7n/153356f16f708d605eea0390efe70286
/*
the <u7n-polygon> custom element provides the extended polygon with rounded corners and resizing
the polygon data is set via the 'polygon-segments' attribute, segments are separated with a semicolon and points within a segment are separated with a comma
each point (except the first on of a segment) must have two numbers for X and Y coordinates respectively
numbers can have optional units however all calculations are performed in pixels regardless of specified units
besides coordinates, points can have modifiers: setting a radius of rounding and setting a variant of rounding
a radius (not the ideal term, but nothing more compelling comes to mind) is set by the keywords 'radius' or 'round' followed by a number, the default radius is 0
a variant is specified by one of the keywords:
'arc' a circular arc between segments, the default variant;
'sharp' a sharp turn between segments without any rounding;
'concave' an inlaid circular arc between segments, not a rounding by itself and kinda an inverse of 'arc';
'quadratic' a quadratic Bezier curve between segments with the point itself acting as the control point;
'cubic' a cubic Bezier curve with the control points halfway between the point itself and the "radius" along the segments
if the first point of a segment has no coordinates but includes a radius and/or a variant, then these values are used to the end of the attribute as the defaults
example: round 10, concave 0 0 round 20, sharp 80 0, 81 19, sharp 100 20, quadratic 100 100, cubic 0 100; cubic, 30 30, arc 30 70, 70 70, arc 70 30
the presence of the 'polygon-resize' attribute makes the polygon resizable to stretch to the size of the element
a resize range is specified by a pair of numbers: points with coordinates equal to or greater than this number pair (ending at the next pair) are shifted linearly
when shifting, a proportionally calculated scaling factor is applied to fill up the size of the element
the calculated scale can be overridden with the keyword 'scale' followed by a number in the range between 0 and 1
example: 35 35 scale 0.4, 65 65
the result is an SVG path set to the --u7n-polygon-path CSS property, and thus available to any children
also on every SVG path recalculation the 'u7n-polygon.path' event is emitted on the element
the event is bubbling and has the SVG path as its 'detail' property
*/
(() => {
const Point = class {
x = 0;
y = 0;
constructor(x = 0, y = 0) {
this.x = x;
this.y = y;
}
toLength() {
return Math.hypot(this.x, this.y);
}
toAngle() {
return Math.atan2(this.y, this.x);
}
asVector(destinationX, destinationY) {
return new this.constructor(destinationX - this.x, destinationY - this.y);
}
asUnit() {
const length = this.toLength();
return new this.constructor(this.x / length, this.y / length);
}
atDistance(distance, direction) {
return new this.constructor(this.x + distance * direction.x, this.y + distance * direction.y);
}
};
const U7nPolygonElement = class extends HTMLElement {
static get _VARIANT_ARC() { return 'arc'; }
static get _VARIANT_SHARP() { return 'sharp'; }
static get _VARIANT_CONCAVE() { return 'concave'; }
static get _VARIANT_QUADRATIC() { return 'quadratic'; }
static get _VARIANT_CUBIC() { return 'cubic'; }
static get _PATTERN_NUMBER() {
return '[+\\-]?(?:[0-9]+(?!\\.)|[0-9]*\\.[0-9]+)(?:[eE][+\\-]?[0-9]+)?';
}
static get _PATTERN_UNIT() {
return '%|[a-zA-Z_\\-][0-9a-zA-Z_\\-]*';
}
static _parseSegmentsString(segmentsString) {
if (segmentsString == null) return null;
const context = {
radius: 0,
variant: this._VARIANT_ARC,
};
const segments = [];
for (const pointsString of segmentsString.split(';')) {
const points = this._parsePointsString(context, pointsString);
if (points === null) return null;
segments.push(points);
}
return segments;
}
static _parsePointsString(context, pointsString) {
const tokenPattern = new RegExp(
[
'\\b(?<radius>radius|round)\\b',
'\\b(?<variant>' + [
this._VARIANT_ARC,
this._VARIANT_SHARP,
this._VARIANT_CONCAVE,
this._VARIANT_QUADRATIC,
this._VARIANT_CUBIC,
].join('|') + ')\\b',
'(?<number>' + this._PATTERN_NUMBER + ')(?<unit>' + this._PATTERN_UNIT + ')?',
'(?<unknown>\\S+)'
].join('|'),
'gi',
);
const points = [];
for (const item of pointsString.split(',')) {
let state = 0;
let xy = [];
let radius = null;
let variant = null;
for (const match of item.matchAll(tokenPattern)) {
if (state === 0 && match.groups.radius != null) {
if (xy.length === 1) {
state = -1;
break;
}
state = 1;
}
else if (state === 1 && match.groups.number != null) {
radius = Math.max(0, parseFloat(match.groups.number));
state = 0;
}
else if (state === 0 && match.groups.variant != null) {
if (xy.length === 1) {
state = -1;
break;
}
variant = match.groups.variant;
}
else if (state === 0 && match.groups.number != null && xy.length < 2) {
xy.push(parseFloat(match.groups.number));
}
else {
state = -1;
break;
}
}
if (state !== 0) return null;
if (points.length === 0 && xy.length === 0) {
if (radius !== null) {
context.radius = radius;
}
if (variant !== null) {
context.variant = variant;
}
continue;
}
if (xy.length !== 2) return null;
points.push({
x: xy[0],
y: xy[1],
radius: radius ?? context.radius,
variant: variant ?? context.variant,
});
}
return points;
}
static _buildPath(segments) {
const path = [];
for (const points of segments) {
if (points.length < 3) continue;
for (const [ idx, item, ] of points.entries()) {
const from = points[idx - 1 >= 0 ? idx - 1 : points.length - 1];
const next = points[idx + 1 < points.length ? idx + 1 : 0];
if (false
|| item.radius == 0
|| item.variant === this._VARIANT_SHARP
|| item.x === from.x && item.y === from.y
|| item.x === next.x && item.y === next.y
) {
path.push([ !idx ? 'M' : 'L', item.x, item.y, ]);
continue;
}
const itemPoint = new Point(item.x, item.y);
const fromVector = itemPoint.asVector(from.x, from.y);
const nextVector = itemPoint.asVector(next.x, next.y);
const fromLength = fromVector.toLength();
const nextLength = nextVector.toLength();
const fromDirection = fromVector.asUnit();
const nextDirection = nextVector.asUnit();
if (item.variant === this._VARIANT_ARC) {
let angle = nextDirection.toAngle() - fromDirection.toAngle();
if (angle < -Math.PI || angle > Math.PI) {
angle -= Math.sign(angle) * 2 * Math.PI;
}
if (Math.abs(angle) < Math.PI / 180 || Math.abs(angle) > Math.Pi / 180 * 179) {
path.push([ !idx ? 'M' : 'L', item.x, item.y, ]);
continue;
}
const angleTangent = Math.tan(Math.abs(angle) / 2);
const distance = Math.min(fromLength / 2, nextLength / 2, item.radius / angleTangent);
const fromPoint = itemPoint.atDistance(distance, fromDirection);
const nextPoint = itemPoint.atDistance(distance, nextDirection);
const radius = distance * angleTangent;
path.push([ !idx ? 'M' : 'L', fromPoint.x, fromPoint.y, ]);
path.push([ 'A', radius, radius, 0, false, angle < 0, nextPoint.x, nextPoint.y, ]);
}
else if (item.variant === this._VARIANT_CONCAVE) {
let angle = nextDirection.toAngle() - fromDirection.toAngle();
if (angle < -Math.PI || angle > Math.PI) {
angle -= Math.sign(angle) * 2 * Math.PI;
}
const distance = Math.min(fromLength / 2, nextLength / 2, item.radius);
const fromPoint = itemPoint.atDistance(distance, fromDirection);
const nextPoint = itemPoint.atDistance(distance, nextDirection);
path.push([ !idx ? 'M' : 'L', fromPoint.x, fromPoint.y, ]);
path.push([ 'A', distance, distance, 0, false, angle > 0, nextPoint.x, nextPoint.y, ]);
}
else if (item.variant === this._VARIANT_QUADRATIC) {
const distance = Math.min(fromLength / 2, nextLength / 2, item.radius);
const fromPoint = itemPoint.atDistance(distance, fromDirection);
const nextPoint = itemPoint.atDistance(distance, nextDirection);
path.push([ !idx ? 'M' : 'L', fromPoint.x, fromPoint.y, ]);
path.push([ 'Q', item.x, item.y, nextPoint.x, nextPoint.y, ]);
}
else if (item.variant === this._VARIANT_CUBIC) {
const distance = Math.min(fromLength / 2, nextLength / 2, item.radius);
const fromPoint = itemPoint.atDistance(distance, fromDirection);
const nextPoint = itemPoint.atDistance(distance, nextDirection);
const fromControl = itemPoint.atDistance(distance / 2, fromDirection);
const nextControl = itemPoint.atDistance(distance / 2, nextDirection);
path.push([ !idx ? 'M' : 'L', fromPoint.x, fromPoint.y, ]);
path.push([ 'C', fromControl.x, fromControl.y, nextControl.x, nextControl.y, nextPoint.x, nextPoint.y, ]);
}
}
path.push([ 'Z', ]);
}
return path;
}
static _stringifyPath(path, precision = 2) {
precision = Math.max(Math.round(precision), 0);
const result = [];
for (const command of path) {
if (result.length) {
result.push(' ');
}
for (const [ idx, item, ] of command.entries()) {
if (typeof item === 'string') {
result.push(item);
}
else if (typeof item === 'boolean') {
result.push(item ? ',1' : ',0');
}
else if (typeof item === 'number') {
let value = item.toFixed(precision);
if (item >= 0) {
value = '+' + value;
}
if (precision) {
value = value.replace(/\.?0+$/, '');
}
result.push(value);
}
}
}
return result.join('');
}
static _parseResizeString(resizeString) {
if (resizeString == null) return null;
const tokenPattern = new RegExp(
[
'(?<scale>scale)',
'(?<number>' + this._PATTERN_NUMBER + ')(?<unit>' + this._PATTERN_UNIT + ')?',
'(?<unknown>\\S+)'
].join('|'),
'gi',
);
const resize = {
ranges: [],
minX: null,
minY: null,
maxX: null,
maxY: null,
get sizeX() { return this.maxX == null || this.minX == null ? null : this.maxX - this.minX; },
get sizeY() { return this.maxY == null || this.minY == null ? null : this.maxY - this.minY; },
x: null,
y: null,
};
if (resizeString === '') return resize;
for (const item of resizeString.split(',')) {
let state = 0;
let xy = [];
let scale = null;
for (const match of item.matchAll(tokenPattern)) {
if (state === 0 && match.groups.scale != null) {
if (xy.length === 1) {
state = -1;
break;
}
state = 1;
}
else if (state === 1 && match.groups.number != null) {
scale = Math.max(0, Math.min(parseFloat(match.groups.number), 1));
state = 0;
}
else if (state === 0 && match.groups.number != null && xy.length < 2) {
xy.push(parseFloat(match.groups.number));
}
else {
state = -1;
break;
}
}
if (state !== 0) return null;
if (xy.length !== 2) return null;
resize.ranges.push({
x: xy[0],
y: xy[1],
scale: scale,
});
}
return resize;
}
static _resizeSegments(segments, resize) {
if (resize.x == null || resize.y == null) return segments;
if (resize.minX == null || resize.minY == null || resize.maxX == null || resize.maxY == null) {
resize.minX = +Infinity;
resize.minY = +Infinity;
resize.maxX = -Infinity;
resize.maxY = -Infinity;
for (const points of segments) {
for (const item of points) {
resize.minX = Math.min(resize.minX, item.x);
resize.minY = Math.min(resize.minY, item.y);
resize.maxX = Math.max(resize.maxX, item.x);
resize.maxY = Math.max(resize.maxY, item.y);
}
}
if (!resize.ranges.length) {
resize.ranges.push({
x: resize.minX + resize.sizeX / 2,
y: resize.minY + resize.sizeY / 2,
scale: null,
});
}
for (const item of resize.ranges) {
item.x = Math.max(resize.minX, Math.min(item.x, resize.maxX));
item.y = Math.max(resize.minY, Math.min(item.y, resize.maxY));
}
resize.ranges.sort((a, b) => {
const deltaX = a.x - b.x;
if (deltaX != 0) return deltaX;
return a.y - b.y;
});
const perItem = 1 / resize.ranges.length;
for (const [ idx, item, ] of resize.ranges.entries()) {
item.scale ??= (idx + 1) * perItem;
}
resize.ranges.at(-1).scale = 1;
}
const resizedSegments = [];
for (const points of segments) {
const resizedPoints = [];
for (const item of points) {
let deltaX = 0;
for (const range of resize.ranges) {
if (range.x > item.x) break;
deltaX = (resize.x - resize.sizeX) * range.scale;
}
let deltaY = 0;
for (const range of resize.ranges) {
if (range.y > item.y) break;
deltaY = (resize.y - resize.sizeY) * range.scale;
}
resizedPoints.push({
x: item.x + deltaX,
y: item.y + deltaY,
radius: item.radius,
variant: item.variant,
});
}
resizedSegments.push(resizedPoints);
}
return resizedSegments;
}
static get _PROPERTY_PATH() { return '--u7n-polygon-path'; }
static get _EVENT_PATH() { return 'u7n-polygon.path'; }
_segments = [];
_resize = null;
_updatePath() {
let segments = this._segments;
if (this._resize) {
segments = this.constructor._resizeSegments(segments, this._resize);
}
const path = this.constructor._buildPath(segments);
const pathString = this.constructor._stringifyPath(path);
this.style.setProperty(this.constructor._PROPERTY_PATH, `"${pathString}"`);
this.dispatchEvent(new CustomEvent(this.constructor._EVENT_PATH, {
bubbles: true,
cancelable: false,
detail: pathString,
}));
return this;
}
static _observerResizeCallback(entries, observer) {
for (const entry of entries) {
entry.target._resize.x = entry.borderBoxSize[0].inlineSize;
entry.target._resize.y = entry.borderBoxSize[0].blockSize;
entry.target._updatePath();
}
}
static _observerResize = new ResizeObserver(this._observerResizeCallback);
static get _ATTRIBUTE_SEGMENTS() { return 'polygon-segments'; }
static get _ATTRIBUTE_RESIZE() { return 'polygon-resize'; }
//static get _ATTRIBUTE_PROPERTY() { return 'polygon-property'; }
static get observedAttributes() {
return [
this._ATTRIBUTE_SEGMENTS,
this._ATTRIBUTE_RESIZE,
//this._ATTRIBUTE_PROPERTY,
];
}
constructor() {
super();
this.attachShadow({ mode: 'open', });
}
_connected = false;
attributeChangedCallback(name, oldValue, newValue) {
if (newValue === oldValue) return;
if (name === this.constructor._ATTRIBUTE_SEGMENTS) {
this._segments = this.constructor._parseSegmentsString(newValue) ?? [];
if (this._connected) {
this._updatePath();
}
}
else if (name === this.constructor._ATTRIBUTE_RESIZE) {
if (this._resize && this._connected) {
this.constructor._observerResize.unobserve(this);
}
this._resize = this.constructor._parseResizeString(newValue);
if (this._resize && this._connected) {
this.constructor._observerResize.observe(this);
}
}
}
connectedCallback() {
this.shadowRoot.innerHTML = '<style>:host { display: block; }</style><slot></slot>';
this._connected = true;
if (this._resize) {
this.constructor._observerResize.observe(this);
}
else {
this._updatePath();
}
}
disconnectedCallback() {
if (this._resize) {
this.constructor._observerResize.unobserve(this);
}
this._connected = false;
this.shadowRoot.innerHTML = null;
}
};
customElements.define('u7n-polygon', U7nPolygonElement);
})();
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.