diff --git a/src/i18n.js b/src/i18n.js index b7c06ed..6b7cb85 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -32,6 +32,15 @@ var OBI18n = (function () { panelDaily: "Günlük ortalama", panelDailyValue: "$1 izlenme", panelDistTitle: "Kanalın son videoları (eskiden yeniye)", + chartMedianLegend: "Kırmızı çizgi: medyan", + chartSelfLegend: "Turuncu: bu video", + chartNotInPool: "Bu video kanalın son $1 videosunun içinde değil", + chartHint: "Her çubuk bir video, yüksekliği izlenmesi. Üzerine gel, tıkla açılsın.", + tipViews: "$1 izlenme", + agoDays: "$1 gün önce", + agoMonths: "$1 ay önce", + agoYears: "$1 yıl önce", + tipThisVideo: "bu video", panelNoScore: "Skor hesaplanamadı: kanalın bu havuzunda en az $1 video gerekiyor.", panelError: "Skor alınamadı: $1", panelNote: "Kanal izlenmeleri YouTube tarafından yuvarlanmış gelir, skor yaklaşıktır. " + @@ -89,6 +98,15 @@ var OBI18n = (function () { panelDaily: "Daily average", panelDailyValue: "$1 views", panelDistTitle: "Channel's recent videos (oldest to newest)", + chartMedianLegend: "Red line: median", + chartSelfLegend: "Orange: this video", + chartNotInPool: "This video is not among the channel's last $1", + chartHint: "Each bar is a video, its height the view count. Hover for details, click to open.", + tipViews: "$1 views", + agoDays: "$1 days ago", + agoMonths: "$1 months ago", + agoYears: "$1 years ago", + tipThisVideo: "this video", panelNoScore: "No score: this channel pool needs at least $1 videos.", panelError: "Score unavailable: $1", panelNote: "Channel view counts come rounded from YouTube, so the score is approximate. " + diff --git a/src/panel.js b/src/panel.js index 06c5099..9bc6eb1 100644 --- a/src/panel.js +++ b/src/panel.js @@ -12,7 +12,6 @@ var OBPanel = (function () { var P = OBParse; var T = OBI18n; - var SVG = "http://www.w3.org/2000/svg"; function el(tag, cls, text) { var node = document.createElement(tag); @@ -21,44 +20,75 @@ var OBPanel = (function () { return node; } - function svgEl(tag, attrs) { - var node = document.createElementNS(SVG, tag); - for (var k in attrs) node.setAttribute(k, attrs[k]); - return node; + function agoText(days) { + if (days == null) return ""; + if (days < 60) return T.t("agoDays", Math.round(days)); + if (days < 730) return T.t("agoMonths", Math.round(days / 30.44)); + return T.t("agoYears", Math.round(days / 365.25)); } /* Kanalın son videolarının izlenme dağılımı, eskiden yeniye. - * Bu video vurgulu, medyan kesikli çizgi. */ + * + * Çubuklar SVG değil düz HTML: SVG ipucu balonu Firefox'ta ancak + * uzun beklemeyle çıkıyor ve ince çubuklarda fareyi yakalamak zor. Burada + * her çubuğun arkasında tam yükseklikte görünmez bir isabet alanı var ve + * balonu kendimiz çiziyoruz. */ function distribution(pool, videoId, median) { var items = pool.slice().reverse(); - var w = 100, h = 46; - var svg = svgEl("svg", { viewBox: "0 0 " + w + " " + h, class: "ob-chart", preserveAspectRatio: "none" }); + var wrap = el("div", "ob-chart-wrap"); + var chart = el("div", "ob-chart"); var max = 0; for (var i = 0; i < items.length; i++) max = Math.max(max, items[i].views || 0); - if (!max) return svg; - var bw = w / items.length; - for (var j = 0; j < items.length; j++) { - var v = items[j].views || 0; - var bh = Math.max(1, (v / max) * (h - 2)); - var bar = svgEl("rect", { - x: (j * bw + bw * 0.12).toFixed(2), - y: (h - bh).toFixed(2), - width: (bw * 0.76).toFixed(2), - height: bh.toFixed(2), - class: items[j].id === videoId ? "ob-bar ob-bar-self" : "ob-bar" - }); - var tip = svgEl("title", {}); - tip.textContent = P.humanCount(v); - bar.appendChild(tip); - svg.appendChild(bar); - } + if (!max) return wrap; + if (median) { - var y = h - (median / max) * (h - 2); - svg.appendChild(svgEl("line", { - x1: 0, x2: w, y1: y.toFixed(2), y2: y.toFixed(2), class: "ob-median" - })); + var line = el("div", "ob-median"); + line.style.bottom = ((median / max) * 100).toFixed(2) + "%"; + chart.appendChild(line); } - return svg; + + var tip = el("div", "ob-tip"); + tip.hidden = true; + + items.forEach(function (item) { + var slot = el("div", "ob-slot"); + var bar = el("div", "ob-bar" + (item.id === videoId ? " ob-bar-self" : "")); + bar.style.height = Math.max(1, (item.views / max) * 100).toFixed(2) + "%"; + slot.appendChild(bar); + + slot.addEventListener("mouseenter", function () { + tip.textContent = ""; + if (item.title) tip.appendChild(el("div", "ob-tip-title", item.title)); + var meta = [T.t("tipViews", P.humanCount(item.views))]; + if (item.age != null) meta.push(agoText(item.age)); + if (item.id === videoId) meta.push(T.t("tipThisVideo")); + tip.appendChild(el("div", "ob-tip-meta", meta.join(" · "))); + tip.hidden = false; + /* Balon grafiğin içinde konumlanır, kenarlardan taşmaması için + * yatayda kırpılır. */ + var left = slot.offsetLeft + slot.offsetWidth / 2; + tip.style.left = Math.min(Math.max(left, 90), chart.offsetWidth - 90) + "px"; + }); + slot.addEventListener("click", function () { + window.open("https://www.youtube.com/watch?v=" + item.id, "_blank", "noopener"); + }); + chart.appendChild(slot); + }); + + chart.addEventListener("mouseleave", function () { tip.hidden = true; }); + chart.appendChild(tip); + wrap.appendChild(chart); + + var legend = el("div", "ob-legend"); + legend.appendChild(el("span", "ob-legend-median", T.t("chartMedianLegend"))); + if (items.some(function (v) { return v.id === videoId; })) { + legend.appendChild(el("span", "ob-legend-self", T.t("chartSelfLegend"))); + } else { + legend.appendChild(el("span", null, T.t("chartNotInPool", items.length))); + } + legend.appendChild(el("span", null, T.t("chartHint"))); + wrap.appendChild(legend); + return wrap; } function row(label, value) { diff --git a/src/score.js b/src/score.js index 4e2ba98..1bba97f 100644 --- a/src/score.js +++ b/src/score.js @@ -53,7 +53,11 @@ var OBScore = (function () { out.push({ id: v.videoId, views: v.views, - age: v.ageDays == null ? null : Math.round(v.ageDays * 10) / 10 + age: v.ageDays == null ? null : Math.round(v.ageDays * 10) / 10, + /* Başlık sadece paneldeki dağılım grafiğinin ipucu balonu için; skorun + * kendisi kullanmaz. Kanal başına 30 başlık önbelleğe birkaç kilobayt + * ekliyor, karşılığında çubuklar hangi video olduklarını söylüyor. */ + title: (v.title || "").slice(0, 70) }); } return out; diff --git a/src/store.js b/src/store.js index 26e200b..df9bfa1 100644 --- a/src/store.js +++ b/src/store.js @@ -11,7 +11,8 @@ var OBStore = (function () { var api = (typeof browser !== "undefined" ? browser : chrome); var local = api.storage.local; - var CACHE_VERSION = 1; + /* 2: baseline videolarına başlık eklendi (panelin grafik ipuçları için). */ + var CACHE_VERSION = 2; var MAX_CHANNELS = 400; /* aşılınca en eski baseline'lar silinir */ var DEFAULTS = { diff --git a/src/style.css b/src/style.css index 3056f84..3cd52e8 100644 --- a/src/style.css +++ b/src/style.css @@ -106,15 +106,97 @@ font-size: 12px; } +.ob-chart-wrap { margin-top: 4px; } + .ob-chart { - display: block; + position: relative; + display: flex; + align-items: flex-end; + gap: 2px; width: 100%; - height: 52px; + height: 96px; + padding-top: 4px; } -.ob-bar { fill: var(--yt-spec-text-secondary, #909090); opacity: 0.45; } -.ob-bar-self { fill: #f59e0b; opacity: 1; } -.ob-median { stroke: #ef4444; stroke-width: 0.6; stroke-dasharray: 2 2; } +/* Isabet alanı çubuğun kendisi değil, tam yükseklikte bu kutu: kısa çubukları + yakalamak için fareyi grafiğin dibine indirmek gerekmesin. */ +.ob-slot { + position: relative; + flex: 1 1 0; + height: 100%; + display: flex; + align-items: flex-end; + cursor: pointer; +} + +.ob-slot:hover { background: var(--yt-spec-10-percent-layer, rgba(128, 128, 128, 0.18)); } + +.ob-bar { + width: 100%; + min-height: 2px; + border-radius: 2px 2px 0 0; + background: var(--yt-spec-text-secondary, #909090); + opacity: 0.5; +} + +.ob-slot:hover .ob-bar { opacity: 0.9; } +.ob-bar-self { background: #f59e0b; opacity: 1; } + +.ob-median { + position: absolute; + left: 0; + right: 0; + height: 0; + border-top: 1px dashed #ef4444; + pointer-events: none; +} + +.ob-tip { + position: absolute; + bottom: 100%; + transform: translateX(-50%); + max-width: 260px; + padding: 6px 8px; + border-radius: 6px; + background: var(--yt-spec-menu-background, #282828); + border: 1px solid var(--yt-spec-10-percent-layer, rgba(128, 128, 128, 0.3)); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); + font-size: 12px; + line-height: 1.4; + pointer-events: none; + z-index: 5; +} + +.ob-tip-title { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ob-tip-meta { color: var(--yt-spec-text-secondary, #909090); } + +.ob-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + margin-top: 6px; + color: var(--yt-spec-text-secondary, #909090); + font-size: 11px; +} + +.ob-legend-median::before, +.ob-legend-self::before { + content: ""; + display: inline-block; + width: 10px; + height: 2px; + margin-right: 5px; + vertical-align: middle; +} + +.ob-legend-median::before { background: #ef4444; } +.ob-legend-self::before { background: #f59e0b; height: 8px; } .ob-note { margin-top: 10px;