Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid. Co-authored-by: Cursor <cursoragent@cursor.com>
197 lines
5.4 KiB
TypeScript
197 lines
5.4 KiB
TypeScript
/**
|
|
* Fast chart data parser - NO AI needed
|
|
* Extracts structured data from text using regex patterns
|
|
*/
|
|
|
|
export interface ChartDataPoint {
|
|
label: string
|
|
value: number
|
|
}
|
|
|
|
export interface ParsedChartData {
|
|
hasData: boolean
|
|
data: ChartDataPoint[]
|
|
suggestedType: 'bar' | 'horizontal-bar' | 'line' | 'area' | 'pie'
|
|
confidence: number
|
|
}
|
|
|
|
/**
|
|
* Parse text for chart data using multiple regex patterns
|
|
* Runs in < 50ms - NO AI calls
|
|
*/
|
|
export function parseChartData(text: string): ParsedChartData {
|
|
const patterns = [
|
|
// Pattern 1: "Label : Value | Label2 : Value2"
|
|
{
|
|
regex: /([^|:|\n]+?)\s*[:|=]\s*(\d+(?:[.,]\d+)?)(?:\s*\||\n|,|$)/gi,
|
|
priority: 1,
|
|
},
|
|
// Pattern 2: "Label: Value, Label2: Value2"
|
|
{
|
|
regex: /([^,:;\n]+?)\s*[:=]\s*(\d+(?:[.,]\d+)?)(?:\s*,|;|\n|$)/gi,
|
|
priority: 2,
|
|
},
|
|
// Pattern 3: "Label - Value" or "Label — Value"
|
|
{
|
|
regex: /([^-—\n]+?)\s*[—-]\s*(\d+(?:[.,]\d+)?)(?:\s*,|\n|$)/gi,
|
|
priority: 3,
|
|
},
|
|
// Pattern 4: Lists with percentages "Label • 45%"
|
|
{
|
|
regex: /([^•%\n]+?)\s*[•·]\s*(\d+)(?:\s*%)?(?:\s*,|\n|$)/gi,
|
|
priority: 4,
|
|
},
|
|
]
|
|
|
|
const seen = new Set<string>()
|
|
let bestMatch: { data: ChartDataPoint[], score: number, type: ParsedChartData['suggestedType'] } | null = null
|
|
|
|
for (const pattern of patterns) {
|
|
const matches: ChartDataPoint[] = []
|
|
let match: RegExpExecArray | null
|
|
|
|
// Reset regex state
|
|
pattern.regex.lastIndex = 0
|
|
|
|
while ((match = pattern.regex.exec(text)) !== null) {
|
|
const label = match[1].trim()
|
|
const valueStr = match[2].trim().replace(',', '.')
|
|
const value = parseFloat(valueStr)
|
|
|
|
if (!isNaN(value) && value > 0 && label && !seen.has(label)) {
|
|
matches.push({ label, value })
|
|
seen.add(label)
|
|
}
|
|
}
|
|
|
|
if (matches.length >= 2) {
|
|
const score = matches.length * pattern.priority
|
|
if (!bestMatch || score > bestMatch.score) {
|
|
bestMatch = {
|
|
data: matches,
|
|
score,
|
|
type: suggestChartType(matches, text),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!bestMatch) {
|
|
return { hasData: false, data: [], suggestedType: 'bar', confidence: 0 }
|
|
}
|
|
|
|
return {
|
|
hasData: true,
|
|
data: bestMatch.data,
|
|
suggestedType: bestMatch.type,
|
|
confidence: Math.min(bestMatch.score / 10, 1),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Suggest chart type based on data and text context
|
|
*/
|
|
function suggestChartType(
|
|
data: ChartDataPoint[],
|
|
text: string
|
|
): ParsedChartData['suggestedType'] {
|
|
const textLower = text.toLowerCase()
|
|
|
|
// Time series indicators
|
|
const timeKeywords = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec',
|
|
'janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
|
|
'q1', 'q2', 'q3', 'q4', 'month', 'mois', 'week', 'semaine', 'year', 'année', 'trend', 'tendance', 'growth', 'croissance']
|
|
|
|
const hasTimeLabels = data.some(d => timeKeywords.some(k => d.label.toLowerCase().includes(k)))
|
|
const hasTimeInText = timeKeywords.some(k => textLower.includes(k))
|
|
|
|
// Proportion indicators
|
|
const proportionKeywords = ['%', 'percent', 'part', 'share', 'répartition', 'distribution', 'segment', 'total']
|
|
const hasProportion = proportionKeywords.some(k => textLower.includes(k))
|
|
|
|
// Check if values sum to ~100 (percentage data)
|
|
const sum = data.reduce((a, b) => a + b.value, 0)
|
|
const isPercentage = Math.abs(sum - 100) < 10 || data.some(d => d.value > 100)
|
|
|
|
// Decision tree
|
|
if (hasProportion || isPercentage) {
|
|
return data.length <= 5 ? 'pie' : 'bar'
|
|
}
|
|
|
|
if (hasTimeLabels || hasTimeInText) {
|
|
return 'line'
|
|
}
|
|
|
|
// Long labels favor horizontal
|
|
if (data.some(d => d.label.length > 15)) {
|
|
return 'horizontal-bar'
|
|
}
|
|
|
|
return 'bar'
|
|
}
|
|
|
|
/**
|
|
* Generate chart suggestions from parsed data
|
|
* NO AI - instant generation
|
|
*/
|
|
export function generateChartSuggestions(data: ChartDataPoint[]): Array<{
|
|
type: 'bar' | 'horizontal-bar' | 'line' | 'area' | 'pie'
|
|
title: string
|
|
data: ChartDataPoint[]
|
|
description: string
|
|
}> {
|
|
const suggestions: ReturnType<typeof generateChartSuggestions> = []
|
|
|
|
// Always include bar chart as fallback
|
|
suggestions.push({
|
|
type: 'bar',
|
|
title: 'Comparaison',
|
|
data,
|
|
description: 'Graphique à barres pour comparer les valeurs',
|
|
})
|
|
|
|
// Add line if we have enough data points
|
|
if (data.length >= 3) {
|
|
suggestions.push({
|
|
type: 'line',
|
|
title: 'Tendance',
|
|
data,
|
|
description: 'Graphique linéaire pour visualiser la progression',
|
|
})
|
|
}
|
|
|
|
// Add area for magnitude
|
|
if (data.length >= 3) {
|
|
suggestions.push({
|
|
type: 'area',
|
|
title: 'Évolution',
|
|
data,
|
|
description: 'Graphique en aires pour souligner le volume',
|
|
})
|
|
}
|
|
|
|
// Add pie if data looks proportional
|
|
const sum = data.reduce((a, b) => a + b.value, 0)
|
|
if (data.length >= 2 && data.length <= 6 && (sum > 90 || data.some(d => d.label.includes('%')))) {
|
|
suggestions.push({
|
|
type: 'pie',
|
|
title: 'Répartition',
|
|
data,
|
|
description: 'Graphique circulaire pour montrer les proportions',
|
|
})
|
|
}
|
|
|
|
// Add horizontal-bar for long labels
|
|
if (data.some(d => d.label.length > 12)) {
|
|
suggestions.push({
|
|
type: 'horizontal-bar',
|
|
title: 'Comparaison',
|
|
data,
|
|
description: 'Barres horizontales pour les labels longs',
|
|
})
|
|
}
|
|
|
|
// Return max 3 suggestions
|
|
return suggestions.slice(0, 3)
|
|
}
|