feat: market events filters, no-auto-bootstrap, star label deoverlap

- MarketEvents: date presets (7j/30j/3m/6m/1an/Tout/Perso), instrument
  impact filter (ticker + min score + direction → auto-sort by inst score),
  sort controls (date/score/nom + asc/desc), clear-all button, count bar
- Market events list endpoint: full SQL JOIN rewrite supporting instrument
  filter, date range, origin, sort_by=instrument_score
- Disable auto-bootstrap on startup (macro/eco/categories) — manual only
- CycleActions: bootstrap group (Macro/Eco/Categories) with force checkbox,
  grouped layout (detection / bootstrap / data / ai / portfolio)
- cycle_actions router: /bootstrap-macro, /bootstrap-eco, /bootstrap-categories
  endpoints + group field on all action catalogue entries
- InstrumentChart: greedy row placement for star labels (4 rows × 20px)
  to eliminate horizontal overlap; labels now inline (★ + text)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-25 21:27:21 +02:00
parent 0b1fcff49c
commit f71e3be3ff
6 changed files with 545 additions and 190 deletions

View File

@@ -175,56 +175,75 @@ export default function InstrumentChart({ priceData, indicators, events = [], he
if (!active) return
starsOverlay.innerHTML = ''
const cw = containerRef.current?.clientWidth ?? 0
const ch = containerRef.current?.clientHeight ?? 0
// ── Collect visible events with their x coords ──────────────────────
type Placed = { xCoord: number; halfW: number; ev: typeof events[0] }
const visible: Placed[] = []
for (const ev of events) {
const xCoord = chart.timeScale().timeToCoordinate(ev.date as any)
if (xCoord === null || xCoord < 0 || xCoord > cw) continue
const title = ev.title.slice(0, 20)
const halfW = Math.max(30, title.length * 6 + 12) // approx px half-width
visible.push({ xCoord, halfW, ev })
}
// Place stars at a fixed band near top of chart — never following the bars
const highPrice = candleMap[ev.date]
let yBase = 22
if (highPrice !== undefined) {
const yHigh = candleSeries.priceToCoordinate(highPrice)
// 130px above bar high, but always capped to top 30px of chart
if (yHigh !== null) yBase = Math.min(30, Math.max(8, yHigh - 130))
// ── Greedy row placement (up to 4 rows, 20px apart) ─────────────────
const ROW_H = 20 // pixels between rows
const MAX_ROWS = 4
// rowEnds[r] = rightmost x already occupied in row r
const rowEnds: number[] = Array(MAX_ROWS).fill(-Infinity)
// Sort by x so greedy works left-to-right
visible.sort((a, b) => a.xCoord - b.xCoord)
for (const { xCoord, halfW, ev } of visible) {
const left = xCoord - halfW
const right = xCoord + halfW + 4
// Find first row where the label fits (no overlap)
let row = 0
for (let r = 0; r < MAX_ROWS; r++) {
if (rowEnds[r] <= left) { row = r; break }
if (r === MAX_ROWS - 1) row = r // last resort: use bottom row
}
rowEnds[row] = right
const yBase = 6 + row * ROW_H
const cat = ev.category ?? 'fundamental'
const color = CAT_COLORS[cat] ?? '#94a3b8'
const score = ev.impact_score ?? 0.5
const sz = score >= 0.8 ? 18 : score >= 0.6 ? 15 : 12
const sz = score >= 0.8 ? 16 : score >= 0.6 ? 13 : 11
const wrap = document.createElement('div')
wrap.style.cssText = [
`position:absolute`, `left:${xCoord}px`, `top:${yBase}px`,
`transform:translateX(-50%)`,
`display:flex`, `flex-direction:column`, `align-items:center`, `gap:2px`,
`display:flex`, `align-items:center`, `gap:3px`,
`pointer-events:auto`, `cursor:default`,
].join(';')
wrap.title = `${ev.title}\n${ev.date}${ev.end_date ? ' → ' + ev.end_date : ''}`
const lbl = document.createElement('div')
lbl.style.cssText = [
`font-size:11px`, `font-family:ui-monospace,monospace`,
`color:${color}`, `white-space:nowrap`,
`text-shadow:0 1px 4px #000,0 0 8px #000,-1px 0 4px #000,1px 0 4px #000`,
`line-height:1`, `max-width:110px`,
`overflow:hidden`, `text-overflow:ellipsis`,
`font-weight:600`,
].join(';')
lbl.textContent = ev.title.slice(0, 22)
const star = document.createElement('div')
const star = document.createElement('span')
star.style.cssText = [
`font-size:${sz}px`, `color:${color}`, `line-height:1`,
`filter:drop-shadow(0 0 4px ${color}80)`,
`text-shadow:0 1px 3px #000`,
`filter:drop-shadow(0 0 3px ${color}80)`,
].join(';')
star.textContent = '★'
wrap.appendChild(lbl)
const lbl = document.createElement('span')
lbl.style.cssText = [
`font-size:10px`, `font-family:ui-monospace,monospace`,
`color:${color}`, `white-space:nowrap`,
`text-shadow:0 1px 4px #000,0 0 6px #000`,
`line-height:1`, `max-width:100px`,
`overflow:hidden`, `text-overflow:ellipsis`,
`font-weight:600`,
].join(';')
lbl.textContent = ev.title.slice(0, 20)
wrap.appendChild(star)
wrap.appendChild(lbl)
starsOverlay.appendChild(wrap)
}
}