【カスタム可視化サンプル】カスタムレーダーチャート
※ 生成AIを使って作成したカスタムVizです。利用は自己責任でお願いします。尚カスタムVizはテクニカルサポートの対象外となりますのでご注意ください
https://scrapbox.io/files/695e55a27177882eb1ab7490.gif
ディメンション
通常選択 x 1
Pivotで選択 x 2
大項目(上記例だと"都道府県")
小項目(上記例だと"市区町村")
メジャー
通常選択 x 1
・デフォルトでは小項目でレーダーチャートを描き、外周に大項目を円で表示
・外周から任意の大項目を選択すると、その項目にZOOMしてレーダーチャートを表示
【実装手順】
実装手順はこちらのURLから御覧ください。基本的にはコピペとファイル名など変えるだけなので簡単です。
https://zenn.dev/google_cloud_jp/articles/48159e8495944d#✍️🤖geminiが作成した手順書の実施
code:manifest.lkml
visualization: {
id: "categorical_radar_viz"
label: "カスタムレーダー"
file: "categorical_radar.js"
dependencies: ["https://d3js.org/d3.v7.min.js"]
}
code:categorical_radar.js
// @charset "UTF-8";
looker.plugins.visualizations.add({
// --- 設定オプション ---
options: {
radar_color: {
type: "string",
label: "レーダーの色 (枠線)",
display: "color",
default: "#6c43e0",
section: "Style"
},
radar_width: {
type: "number",
label: "レーダー線の太さ",
default: 3,
min: 1,
max: 10,
step: 1,
section: "Style"
},
radar_opacity: {
type: "number",
label: "レーダーの不透明度 (0-1)",
default: 0.8, // 線なのでデフォルトを少し濃くしました
min: 0,
max: 1,
step: 0.1,
section: "Style"
},
color_scheme: {
type: "string",
label: "カテゴリの色使い",
display: "select",
values: [
{ "Vivid": "vivid" },
{ "Pastel": "pastel" },
{ "Cool": "cool" }
],
default: "vivid",
section: "Style"
}
},
create: function(element, config) {
element.innerHTML = "";
this.container = d3.select(element)
.append("svg")
.attr("width", "100%")
.attr("height", "100%");
this.mainGroup = this.container.append("g").attr("class", "main-group");
this.bgCircle = this.mainGroup.append("circle")
.attr("fill", "transparent")
.attr("cursor", "pointer");
this.style = d3.select(element).append("style");
this.style.text(`
.radar-axis-line { stroke: #e0e0e0; stroke-width: 1px; transition: opacity 0.5s; }
.radar-label { font-size: 11px; fill: #555; font-family: sans-serif; opacity: 0; pointer-events: none; text-shadow: 0 1px 2px #fff; }
.category-path { cursor: pointer; transition: opacity 0.3s; stroke: #fff; stroke-width: 1px; }
.category-path:hover { opacity: 0.8; }
/* フォント設定 */
.category-text {
font-family: "Hiragino Kaku Gothic ProN", "Meiryo", sans-serif;
pointer-events: none;
}
/* レーダーの線のトランジション設定 */
.radar-blob {
transition: stroke 0.5s, stroke-width 0.5s;
stroke-linejoin: round;
fill: none; /* 塗りつぶしなし */
}
.guide-circle { fill: none; stroke: #ddd; stroke-dasharray: 4,4; pointer-events: none; }
.error-message { font-family: sans-serif; fill: #d32f2f; font-size: 14px; text-anchor: middle; }
.back-hint { font-family: sans-serif; }
`);
this.activeCategory = null;
},
updateAsync: function(data, element, config, queryResponse, details, done) {
this.clearErrors();
if (!queryResponse.fields.pivots || queryResponse.fields.pivots.length < 2) {
this.addError({
title: "データ構成エラー",
message: "このチャートには「2つのピボット」が必要です。(例: 都道府県 > 市区町村)"
});
return;
}
if (data.length === 0) return;
const row = data0;
const pivotFields = queryResponse.fields.pivots;
const measureName = queryResponse.fields.measures0.name;
const rawPivots = queryResponse.pivots;
let colorScale;
if (config.color_scheme === 'pastel') {
colorScale = d3.scaleOrdinal(d3.schemePastel1);
} else if (config.color_scheme === 'cool') {
colorScale = d3.scaleOrdinal(d3.schemeBlues9);
} else {
colorScale = d3.scaleOrdinal(d3.schemeSet2);
}
let processedAxes = [];
let categoryMap = {};
rawPivots.forEach((pivot, i) => {
const outerVal = pivot.data[pivotFields0.name];
const innerVal = pivot.data[pivotFields1.name];
const cell = rowmeasureNamepivot.key;
const value = cell.value !== null ? cell.value : 0;
const rendered = cell.rendered || value;
if (!categoryMapouterVal) {
categoryMapouterVal = {
id: outerVal,
name: outerVal,
count: 0,
color: colorScale(outerVal)
};
}
categoryMapouterVal.count++;
processedAxes.push({
id: pivot.key,
label: innerVal,
catId: outerVal,
value: value,
rendered: rendered
});
});
const categories = Object.values(categoryMap);
const width = element.clientWidth;
const height = element.clientHeight;
const margin = 80;
const maxRadius = Math.min(width, height) / 2 - margin;
const radarRadius = maxRadius * 0.8;
const ringInner = maxRadius * 0.85;
const ringOuter = maxRadius;
this.mainGroup.attr("transform", translate(${width/2}, ${height/2}));
this.bgCircle.attr("r", maxRadius + margin);
const maxValue = d3.max(processedAxes, d => d.value) * 1.1 || 10;
const rScale = d3.scaleLinear().range(0, radarRadius).domain(0, maxValue);
const getAngles = (activeCat) => {
const activeAxes = (activeCat === null)
? processedAxes
: processedAxes.filter(d => d.catId === activeCat);
const count = activeAxes.length;
if (count === 0) return { angleMap: {}, activeAxes: [] };
const slice = (2 * Math.PI) / count;
let angleMap = {};
activeAxes.forEach((d, i) => {
angleMapd.id = i * slice - Math.PI / 2;
});
processedAxes.forEach(d => {
if (angleMapd.id === undefined) angleMapd.id = -Math.PI / 2;
});
return { angleMap, activeAxes };
};
const render = () => {
const { angleMap } = getAngles(this.activeCategory);
const t = d3.transition().duration(1000).ease(d3.easeCubicOut);
// ガイドサークル
const levels = 0.25, 0.5, 0.75, 1;
const guides = this.mainGroup.selectAll(".guide-circle")
.data(this.activeCategory === null ? levels : []);
guides.join(
enter => enter.append("circle").attr("class", "guide-circle").attr("r", 0).call(e => e.transition(t).attr("r", d => radarRadius * d)),
update => update.call(u => u.transition(t).attr("r", d => radarRadius * d).style("opacity", 1)),
exit => exit.call(x => x.transition(t).attr("r", 0).style("opacity", 0).remove())
);
// 軸線
const axes = this.mainGroup.selectAll(".radar-axis-line")
.data(processedAxes, d => d.id);
axes.join(
enter => enter.append("line")
.attr("class", "radar-axis-line")
.style("opacity", 0),
update => update.transition(t)
.style("opacity", d => (this.activeCategory === null || d.catId === this.activeCategory) ? 1 : 0)
.attr("x1", 0).attr("y1", 0)
.attr("x2", d => {
if (this.activeCategory !== null && d.catId !== this.activeCategory) return 0;
return rScale(maxValue) * Math.cos(angleMapd.id);
})
.attr("y2", d => {
if (this.activeCategory !== null && d.catId !== this.activeCategory) return 0;
return rScale(maxValue) * Math.sin(angleMapd.id);
}),
exit => exit.remove()
);
// --- レーダーパス (修正: 枠線のみ & 色更新ロジック) ---
const lineGenerator = d3.lineRadial()
.curve(d3.curveLinearClosed)
.radius(d => rScale(d.value))
.angle(d => angleMapd.id + Math.PI / 2);
const pathData = (this.activeCategory === null)
? processedAxes
: processedAxes.filter(d => d.catId === this.activeCategory);
const radarPath = this.mainGroup.selectAll(".radar-blob")
.data(pathData);
radarPath.join(
enter => enter.append("path")
.attr("class", "radar-blob")
.attr("d", lineGenerator)
.style("fill", "none") // ★塗りつぶしなし
.style("stroke", config.radar_color) // ★枠線色
.style("stroke-width", config.radar_width || 3) // ★太さ
.style("stroke-opacity", 0)
.call(e => e.transition(t).style("stroke-opacity", config.radar_opacity)),
update => update.transition(t)
.attr("d", lineGenerator)
.style("stroke", config.radar_color) // ★更新時にも色を適用(これで色変更が効きます)
.style("stroke-width", config.radar_width || 3)
.style("stroke-opacity", config.radar_opacity),
exit => exit.remove()
);
// ラベル
const labels = this.mainGroup.selectAll(".radar-label")
.data(processedAxes, d => d.id);
labels.join(
enter => enter.append("text").attr("class", "radar-label").style("opacity", 0),
update => update.transition(t)
.attr("x", d => (rScale(maxValue) + 15) * Math.cos(angleMapd.id))
.attr("y", d => (rScale(maxValue) + 15) * Math.sin(angleMapd.id))
.style("opacity", d => (this.activeCategory !== null && d.catId === this.activeCategory) ? 1 : 0)
.attr("text-anchor", d => {
const deg = (angleMapd.id * 180 / Math.PI) % 360;
if (deg > 90 || deg < -90) return "end";
return "start";
})
.text(d => ${d.label}: ${d.rendered}),
exit => exit.remove()
);
// カテゴリリング
const pie = d3.pie().value(d => d.count).sort(null).padAngle(0.02);
const arc = d3.arc().innerRadius(ringInner).outerRadius(ringOuter).cornerRadius(4);
const labelArc = d3.arc().innerRadius(ringOuter + 15).outerRadius(ringOuter + 15);
const pieData = pie(categories);
const ringGroup = this.mainGroup.selectAll(".ring-group").data(0);
ringGroup.enter().append("g").attr("class", "ring-group");
const targetOpacity = this.activeCategory === null ? 1 : 0;
const targetScale = this.activeCategory === null ? 1 : 1.3;
this.mainGroup.select(".ring-group")
.transition(t)
.style("opacity", targetOpacity)
.attr("transform", scale(${targetScale}));
this.mainGroup.select(".ring-group").selectAll("path")
.data(pieData, d => d.data.id)
.join(
enter => enter.append("path")
.attr("class", "category-path")
.attr("d", arc)
.attr("fill", d => d.data.color)
.each(function(d) { this._current = d; }),
update => update
.attr("fill", d => d.data.color)
.transition(t)
.attrTween("d", function(d) {
const i = d3.interpolate(this._current, d);
this._current = i(0);
return t => arc(i(t));
})
);
// カテゴリテキスト (黒色・太字)
this.mainGroup.select(".ring-group").selectAll("text")
.data(pieData, d => d.data.id)
.join(
enter => enter.append("text")
.attr("class", "category-text")
.attr("transform", d => translate(${labelArc.centroid(d)}))
.attr("dy", "0.35em")
.attr("text-anchor", "middle")
.text(d => d.data.name)
.style("opacity", 0)
.style("fill", "#000000")
.style("font-weight", "bold")
.style("font-size", "14px")
.call(e => e.transition(t).style("opacity", 1)),
update => update
.style("fill", "#000000")
.style("opacity", 1)
.transition(t)
.attr("transform", d => translate(${labelArc.centroid(d)}))
.text(d => d.data.name)
);
// イベントリスナー
this.mainGroup.select(".ring-group").selectAll("path")
.on("click", (e, d) => {
e.stopPropagation();
this.activeCategory = d.data.id;
render();
});
this.bgCircle.on("click", () => {
if (this.activeCategory !== null) {
this.activeCategory = null;
render();
}
});
const backText = this.mainGroup.selectAll(".back-hint").data(this.activeCategory !== null ? 1 : []);
backText.join(
enter => enter.append("text")
.attr("class", "back-hint")
.attr("y", height/2 - 20)
.attr("text-anchor", "middle")
.text("Click center to return")
.style("font-size", "10px")
.style("fill", "#999")
.style("opacity", 0)
.call(e => e.transition(t).delay(500).style("opacity", 1)),
update => update,
exit => exit.remove()
);
};
render();
done();
}
});
作成者:tomoya