SVG矢印を動的変更するカスタム要素を自作した
サイズ、色、角度、角丸、塗りつぶし是非を任意に指定できる。
成果物

特徴
- JSファイル一つ読み込めば使える(SVGやWOFF2等は不要)
- HTML属性で矢印を調整できる(サイズ、色、角度、角丸、塗りつぶし是非)
- SVGパスデータをもつ図形は一つだけ
id属性値icon-symbol-arrowを使用している- 回転は
transform="rotate(...)"を使用している
概要
1. Inkscapeで矢印SVG画像を生成する
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M120.137 19.176 11.314 128l108.823 108.824V176H248V80H120.137V19.176z" style="pointer-events:all;opacity:1;fill:none;fill-opacity:0;stroke:currentColor;stroke-width:16;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"/></svg>
pointer-events:all;は手動で追加した。fill:none;があるとその部分のマウスイベントまで消えてしまうのを防ぐ。
2. カスタム要素で矢印SVG画像の属性を変更できるようにする
class ArrowIconElement extends HTMLElement {...} customElements.define("icon-arrow", ArrowIconElement, {extends:'i'});
全コード
arrow-icon.js
class ArrowIconElement extends HTMLElement { static get observedAttributes() {return 'sz dir col wid join limit fill'.split(' ');} constructor() { super(); this._id = `icon-symbol-arrow`; this._sz = `1em`; this._col = `currentColor`; this._dir = `0`; this._wid = `16`; this._join = `miter`; // arcs|bevel|miter|miter-clip|round this._limit = `4`; this._fill = `0`; // fill-opacity this._scale = `1`; if (!document.querySelector(`#${this._id}`)) {document.querySelector('head').append(this.#getArrowSymbol())} } connectedCallback() { setTimeout(()=>{ console.log(this.textContent) this.append(this.#mkEl()) }); } disconnectedCallback() {} adoptedCallback() {} attributeChangedCallback(name, oldValue, newValue) { console.log(`属性 ${name} が変更されました。`); let attrs = {} if ('sz'===name) {this._sz = newValue} else if ('dir'===name) {this._dir = newValue} else if ('col'===name) {this._col = newValue} else if ('wid'===name) {this._wid = newValue} else if ('join'===name) {this._join = newValue} else if ('limit'===name) {this._limit = newValue} else if ('fill'===name) {this._fill = (Number.isFinite(Number(newValue))) ? newValue : (this.hasAttribute(name) ? 1 : 0);} if (this.children && this.children[0]) {this.children[0].replaceWith(this.#mkEl())} } #mkEl() {return this.#getUse(this.#getSize(), this.#getTransform())} #getSize() {return ({width:this._sz, height:this._sz})} #getTransform() {const T = [this.#getRotate(), this.#getScale()].filter(v=>v); return T ? ({transform:T.join(' ')}) : ({});} #getRotate() {const deg = this.#getDegree(); return 0===deg ? '' : `rotate(${deg},128,128)`} #getScale() {return '1'===this._scale || 1===this._scale ? '' : `scale(${this._scale}, ${this._scale})`} #getDegree() { if ('left'===this._dir) {return 0} else if ('top-left'===this._dir) {return 45} else if ('top'===this._dir) {return 90} else if ('top-right'===this._dir) {return 135} else if ('right'===this._dir) {return 180} else if ('bottom-right'===this._dir) {return 225} else if ('bottom'===this._dir) {return 270} else if ('bottom-left'===this._dir) {return 315} else { const deg = parseInt(this._dir); if (Number.isNaN(deg)){throw new TypeError(`角度は0〜359かtop,bottom,left,right等の文字列で指定してください。`)} else {return deg} } } #getUse(svgAttrs={}, useAttrs={}) { console.log(svgAttrs, useAttrs) const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); for (let [k,v] of Object.entries(svgAttrs)) { svg.setAttribute(k, v); } for (let [k,v] of Object.entries(useAttrs)) { use.setAttribute(k, v); } use.setAttribute('href', `#${this._id}`); use.setAttribute('fill', this._col); use.setAttribute('stroke', this._col); use.setAttribute('stroke-width', this._wid); use.setAttribute('stroke-linejoin', this._join); use.setAttribute('stroke-miterlimit', this._limit); console.log(this._fill) use.setAttribute('fill-opacity', this._fill); // 0.0〜1.0 svg.setAttribute('viewBox', `0 0 256 256`); svg.append(use); return svg } #mkSvgEl(name){return document.createElementNS('http://www.w3.org/2000/svg', name)} #getArrowSymbol() { const [svg, defs, symbol, path] = 'svg defs symbol path'.split(' ').map(n=>this.#mkSvgEl(n)) path.setAttribute('style', `pointer-events:all;opacity:1;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1`) path.setAttribute('d', 'M120.137 19.176 11.314 128l108.823 108.824V176H248V80H120.137V19.176z') symbol.setAttribute('viewBox', '0 0 256 256') symbol.setAttribute('id', this._id) symbol.append(path) defs.append(symbol) svg.append(defs) return svg } } customElements.define("icon-arrow", ArrowIconElement, {extends:'i'});
3. HTMLで使用する
自作カスタム要素をロードする。
<script src="js/arrow-icon.js"></script>
矢印を表示する。
<i is="icon-arrow"></i>
上記に以下の属性を任意追加できる。
| 属性 | 値 | 対応公式属性 |
|---|---|---|
sz |
1em |
width, height |
col |
currentColor |
fill,stroke |
dir |
0〜359,top,bottom,left,right,top-left,top-right,bottom-left,bottom-right |
transform="rotate(...)" |
wid |
1〜16〜 |
stroke-width |
join |
arcs,bevel,miter,miter-clip,round |
stroke-linejoin |
limit |
4 |
stroke-miterlimit |
fill |
0.0〜1.0 |
fill-opacity |
たとえば以下のように書く。
<i is="icon-arrow" dir="top" col="red" sz="5em" join="round" fill="1"></i>
他の方法と比較
PNG等のラスタ画像だと色を変えるのにCSSのfilterを使わねばならずcolor:redのような判りやすいコードで書けない。だが今回はcol="red"のように要素の属性値にCSSと同じ様に色を指定できる。
SVGフォントだと360度を自由に指定できない。だが今回はSVGのtransform="rotate(...)"を使って任意の角度を指定して回転できるようにした。
OTF,WOFF2等では塗りつぶした図形と塗りつぶさない図形は別々のパスデータとして保存されるため容量が増える。だが今回はSVGのfill-opacity属性値を変更するだけで対応させたため、パスデータは一つのみで済む。
課題
wid(stroke-width)が16(SVG作成時点の値)を超過するとmiterが効かなくなりbevelになる(角がとれて平になる)join(stroke-linejoin)をmiterにしても変わらなかったlimit(stroke-miterlimit)を4(SVG作成時点の値)より大きくしても変わらなかった
dir(transfrom="rotate(...)")が15等の場合、矩形をはみ出た角の部分が見切れる- サイズを微妙に小さくすればいいと思うが、その計算方法が判らない(数学ムリ)
- サイズ微調整は
transform="scale(X,Y)"で可能
- サイズ微調整は
- サイズを微妙に小さくすればいいと思うが、その計算方法が判らない(数学ムリ)
col(fill,stroke,color)- 類似図形
- 双方向
- 反対方向にも矢印の頭がついている図形
- 二個
- 反対方向の矢印とニコイチ
- 四個
- 内側、外側に向いた矢印で、よく画面の全画面化と元に戻すアイコンで使われている奴
- 双方向
- プレーンテキスト
- HTMLから文字列コピーしたとき矢印は何もコピーされない(それでいいのか、
arrowや→等をコピーさせるべきか。可能なのか。どうやるのか)
- HTMLから文字列コピーしたとき矢印は何もコピーされない(それでいいのか、
フォントは太さを100〜900の100刻みで指定できる。グリフのデータは太さ一つずつに個別の図形がある。容量が増えるのでフォントを辞め、SVGでstroke-widthを変更するだけで対応できないかと思って試してみた。
太さは変更できたが副作用があった。残念ながら作成時の太さを超えるとstroke-linejoinがbavelになってしまった(角が尖るmiterを指定しているのに平なbevelになる)。20くらいまでなら問題なさそうではあるが。何とか動的に対応したかったのだが、どう計算すればいいやら判らない。太さに応じてstroke-miterlimitも大きくしてやればいいかと思ったのだが、何ら変化が見受けられなかった。謎。
回転は単にtransform="rotate(...)"すればいいと思っていた。実際、十字キーの八方向くらいは問題ない。しかし15度などの角度では端が見切れることがある。そのときだけサイズを小さくすればいいのだろうが、計算方法が判らず。viewBoxを小さくすればいいのか、transform="scale(...)"で拡縮すべきなのかも不明。
きっとSVGを理解し数学ができる人なら対処できるのだろう。私にはムリ。
ライト/ダークモード変更時に適した色にしたい件はどうするか。案の一つとしては「注意色」などの抽象的な概念となる色をいくつか用意しておき、ライト/ダーク各モードで見やすい色を定義しておく方法がある。その場合、使える色は限られてしまう。もしくは新しい色を使う場合、毎回ライト/ダーク各モードの場合の配色を考慮した色を作らねばならない。
配色面倒くさい。でもリンクテキストの色くらいは考えるべきか。矢印だけを考えても色ごとに別の意味を持たせることも考えられる。ただし色盲の人に対するアクセシビリティが損なわれそうだが。
ニコイチ矢印も欲しい。単純にtrasform(1, 0.5)等で縦か横に半分にしてもう一個は回転させた奴を配置すれば作れる。実際、以下の記事で作成していた。
ただ、Y軸だけ半分にすると、線の太さがY軸だけ半分になって歪な形になってしまう。縦横両方同じ比率で拡縮するなら問題ないので四個一組の図形は作っても良かったか。
双方向の矢印は三角形と長方形に分解して結合すれば動的生成できるはずだが、Inkscapeにあるメニュー→パス→統合に該当する処理をJSで実装せねばならない。ムリ。せめて誰かInkscapeの動作をスクリプト表現してGUI操作でやることを完全再現できるインタプリタ言語みたいなの書いてくれないかな。もっと大変そう。
HTMLで矢印を文字列コピーしたとき、今回の矢印は何もコピーされない。GoogleマテリアルSymbolは合字を使っているのでarrowとかの文字列がコピーできる。そのほうが判りやすいが、そもそも文字列コピーしたときの可読性を担保すべきか疑問。できたほうがいいけど、そのために難しいフォント作成を学習するのは嫌。
多数の変数が用意されたら考慮すべき点も増えてしまう。自由度が高いのは嬉しいのだが。SVGのd属性値から自力で作成すれば私のやりたいことも完璧にできると思う。ただ、その技術力や数学力がない。脳みそが追いつかない。
最も難しいのが錯視。人間の目による視覚的錯覚が起きる。たとえば同じサイズなのに角度や配置などによって実際とは異なるように見えてしまうことがある。今回の矢印もサイズ2emで上向きの時、頭の斜め線が太く、他の縦や横の線は細く見える。たぶんこれは錯視。または実際に計算による微妙な誤差なのか、私のデータ作成に問題があったのか、私の目が悪いのか。
Unicodeでも矢印はそれなりに用意されている。以下のあたりが使いそう。でも不足があったり微妙に気に入らないから自分で作りたくなってしまう。フォントのグリフ次第だから意図した図形が表示される保障がない。
⇩⇧⇦⇨(方向数が足りない) ↑↓←→↖↗↙↘(大きさが不均等) ↕↔(小さすぎる) ⇅⇆ 🔃 ↖↗↘↙ ↙↘↗↖
所感
矢印にも色々ある。細かい所が気になるが、このままだと矢印に人生を捧げることになりそう。でもよく使うからなぁ。
矢印だけの専用要素を作ってもこれだけ不満があり障害があり最善手が何かも判らないとは。
たすけて。