Chart Annotations in LO Calc

Currently I use LO 26.2.4.2 and Cinnamon 22.3. Changing last year from Excel I’ve been able to convert all macros and even enhance some. But one task remains, chart annotation. Shown is a now old image of what was acheived in Excel and all was simply done. In an effort to replicate, open source AI has suggested many solutions many entitled “The Complete, Fully Corrected, & Error-Free Code” but result in system hangs, chart removal, chart downsizing, no datalabels drawn, datalabels shown off plot area, incorrect vertical alignment, all datalabels bunched under one event date and the last effort shows the first datalabel(event) 7 months later than the real date and the last datalabel 7 months early. No suggestions attached datalabels to the lowest data series. AI explained how LO uses a different system to draw charts and it is complex.
With numerous failures the question has to be asked can what was once done be replicated in LO? If not, then I am prepared to compromise.
In research I discovered this topic has received little ‘official’ attention. Documentation shows how to manually attach single and multiple labels but that is not appropriate here.

Has anyone found similar issues and a solution or used an alternative?

Things to know:
Events are listed against dates on another sheet in a column.
So far, there are only 10 events listed in a period starting 2nd January 2015 and ending 31st December 2027.
Other charts on different sheets use the same events.
All date columns contiguously list weekdays only.
Chart dimensions and position remain constant on each sheet but vary sheet to sheet.
All charts are chart2.

Excel Chart

LO Chart

edited to remove data source

The latest code to do that:

Sub ANNOTATE_EVENTS()
	Dim oDoc As Object: oDoc = ThisComponent
	Dim oSheet10 As Object: oSheet10 = oDoc.getSheets().getByName("FX_Rates_2")
	Dim oSheet12 As Object: oSheet12 = oDoc.getSheets().getByName("Gold_Fixes_1")
	Dim oDrawPage As Object: oDrawPage = oSheet12.getDrawPage()
	Dim oCellRange As Object
	Dim sCellValue As String
	Dim vDataArray As Variant
	Dim nCount As Long, i As Long, x As Long, y As Long
	Dim lrB10 As Long, lrB12 As Long
	Dim vNoteableEvents() As Long
	Dim sEventText As String
	
	''create NoteableEvents Array
	With oSheet10 ''FX_Rates_2
		lrB10 = Get_Last_Row_With_Data(1, 10)    
		oCellRange = .getCellRangeByPosition(58, 12, 58, lrB10-1)
		vDataArray = oCellRange.getDataArray()
		nCount = 0
		For i = LBound(vDataArray) To UBound(vDataArray)
			sCellValue = Trim(CStr(vDataArray(i)(0)))
			If sCellValue <> "" Then
				ReDim Preserve vNoteableEvents(nCount)
				vNoteableEvents(nCount) = i
				nCount = nCount + 1  
			End If
		Next i
	End With
	
	''exit if no recorded events
	If nCount = 0 Then
		MsgBox "There are no events recorded in this period."
		Exit Sub
	End If
	
	''extract chart visual dimensions
	Dim oChartsContainer As Object
	Dim oChartWrapper As Object
	Dim oChartShape As Object
	Dim chartLeft As Long, chartTop As Long, chartWidth As Long, chartHeight As Long
	Dim totalRowsCount As Long
	
	oChartsContainer = oSheet12.getCharts()
	oChartWrapper = oChartsContainer.getByIndex(0)
	totalRowsCount = lrb10-12
	
	''get primary OLE2 chart shape properties
	For i = 0 To oDrawPage.getCount() - 1
		oChartShape = oDrawPage.getByIndex(i)
		If oChartShape.supportsService("com.sun.star.drawing.OLE2Shape") Then
			chartLeft = oChartShape.getPosition().X
			chartTop = oChartShape.getPosition().Y
			chartWidth = oChartShape.getSize().Width
			chartHeight = oChartShape.getSize().Height
			Exit For
		End If
	Next i
	
	''push chart to background plane so new lines draw in front of it
	oChartShape.ZOrder = 0
	
	''establish explicit mathematical bounds of inner data graph grid frame
	Dim plotLeft As Long, plotTop As Long, plotWidth As Long, plotHeight As Long	
	plotLeft = chartLeft + (chartWidth * 0.11)
	plotTop = chartTop + (chartHeight * 0.09)
	plotWidth = chartWidth * 0.81
	plotHeight = chartHeight * 0.72
	
	''persistent layout trackers allocated outside loop passes
	Dim aLinePoints(1) As New com.sun.star.awt.Point
	Dim oShapePos As New com.sun.star.awt.Point
	Dim oShapeSize As New com.sun.star.awt.Size
	Dim oLeaderLine As Object
	Dim oTextShape As Object
	Dim targetPointX As Long, targetPointY As Long
	
	''systemic multi-stack collision lookback engine trackers
	Dim aAllocatedX() As Long
	Dim aAllocatedYOffsets() As Long
	Dim nAllocatedCount As Long
	nAllocatedCount = 0
	
	''remove previous datalabels and leaderlines
	Dim oShape As Object
		For x = oDrawPage.getCount() - 1 To 0 Step -1
		oShape = oDrawPage.getByIndex(x)
		If oShape.Name = "" Then
			oDrawPage.remove(oShape)
		End if
	Next x
	
	''process individual annotations	
	For x = 0 To UBound(vNoteableEvents)
		sEventText = Trim(CStr(vDataArray(vNoteableEvents(x))(0)))
		
		'calculate precise locations based purely on chronological cell sequence,
		'completely removing the 18-month offset drift and reverse order bugs.
		targetPointX = plotLeft + CLng((vNoteableEvents(x) / totalRowsCount) * plotWidth)
		'drop target point directly to midline profile height of your graph layout
		targetPointY = plotTop + (plotHeight / 1)	''originally (plotHeight / 2)
		
		'pixel proximity multi-stack engine lookback
		Dim currentYOffset As Long
		currentYOffset = 1000 '' originall 500 = base clearance depth below data nodes
		
		For y = 0 To nAllocatedCount - 1
			If Abs(targetPointX - aAllocatedX(y)) < 3600 Then
				If aAllocatedYOffsets(y) >= currentYOffset Then
					currentYOffset = aAllocatedYOffsets(y) - 600 ' Stack up by 6.0mm steps
				End If
			End If
		Next y
		
		ReDim Preserve aAllocatedX(nAllocatedCount)
		ReDim Preserve aAllocatedYOffsets(nAllocatedCount)
		aAllocatedX(nAllocatedCount) = targetPointX
		aAllocatedYOffsets(nAllocatedCount) = currentYOffset
		nAllocatedCount = nAllocatedCount + 1
		
		''build canvas graphics segment layer
		''create and position leaderline
		oLeaderLine = oDoc.createInstance("com.sun.star.drawing.PolyLineShape")
		oDrawPage.add(oLeaderLine)
		ReDim aLinePoints(1) As New com.sun.star.awt.Point
		aLinePoints(0).X = targetPointX
		aLinePoints(0).Y = targetPointY
		aLinePoints(1).X = targetPointX
		aLinePoints(1).Y = targetPointY + currentYOffset
		oLeaderLine.Polygon = aLinePoints()
		
		''format leaderline
		With oLeaderLine
			.LineColor = RGB(255, 0, 255) ''Magenta
			.LineWidth = 80               ''0.2 mm fine-line weight path
			.LineStyle = com.sun.star.drawing.LineStyle.SOLID
			.LineTransparence = 70
		End With
		
		''move leaderline to foreground layer
		oLeaderLine.ZOrder = 10 + x
		
		''add custom datalabel
		oTextShape = oDoc.createInstance("com.sun.star.drawing.TextShape")
		oDrawPage.add(oTextShape)
		
		''fix shape dimensions, autosize width after adding string
		oShapeSize.Width = 3500       ' 35 mm box width
		oShapeSize.Height = 450       ' 4.5 mm text height
		oTextShape.setSize(oShapeSize)
		
		oShapePos.X = targetPointX - (oShapeSize.Width / 2)
		oShapePos.Y = targetPointY + currentYOffset
		oTextShape.setPosition(oShapePos)
		
		oTextShape.setString(sEventText)
		
		With oTextShape
			.CharFontName = "Liberation Mono"
			.CharHeight = 10
			''force autosize width
			.TextAutoGrowWidth = True
			.CharWeight = com.sun.star.awt.FontWeight.MEDIUM''BOLD
			.CharColor = RGB(0,0,0)
			.TextHorizontalAdjust = com.sun.star.drawing.TextHorizontalAdjust.CENTER
			.FillStyle = com.sun.star.drawing.FillStyle.SOLID
			.FillColor = RGB(255,255,255) ' Solid white background blocks chart grids
			.FillTransparence = 50
		End With
		
		''move text box shape to absolute foreground layer
		oTextShape.ZOrder = 100 + x
	Next x
	
	Erase vNoteableEvents
	Erase aAllocatedX
	Erase aAllocatedYOffsets
	MsgBox "Done"
End Sub

Forget AI. It offers non-existent UNO components and methods. When you use it, you end up having to tell it to stop spouting BS about code that it claims is guaranteed to work.
Instead, show it when the code fails and provide the error message. That way, in the best-case scenario, it might find a solution.

Yes I use the same tactic, keep giving feedback until it delivers something that works. Frustratingly it appeared to deliver something which cured the X axis issue but the Y axis was still wrong, so feedback yet again. Its next attempt was the killer, it caused a crash and I had not previously saved.Though I could recover the thread, for some reason the vital part was missing. My bad.

Have you made any progress with your Calc project? Could you perhaps upload it here?

No solution yet.
A development, ANNOTATE_EVENTS_2, shows Y axis dates plotted on a series but not the required event label. And labels are not staggered to avoid overlaps if event dates are close. No leaderlines are drawn.
Attached is a commented ‘bare bones’ workbook.
Please be aware when testing that ANNOTATE_EVENTS_1 includes label removal but ANNOTATE_EVENTS_2 does not. To clear those test results it is necessary to close the workbook without saving and reopen.
Thank you for the interest.

Edit ---- please use this version, despite care I got a run error in EVENTS_2. This runs.
CHART_ANNOTATION v2.ods (595.4 KB)

https://matplotlib.org/stable/gallery/text_labels_and_annotations/annotation_basic.html

karolus
Thank you I haven’t seen the doc before. Looks like it could be a useful alternative strategy.

Another way could be using the underlying cells after making the graph transparent. That could avoid using macros to get it done.

Apologies sokol92. That came out wrong and I do not know a way to correct.

No solution yet.

The previous version was better, but I saved it at the wrong stage. I should have made a copy of it. If you still have it would you upload it here again.

Oops I have overwritten the oginal posted CHART_ANNOTATION wbook because ANNOTATE_EVENTS_2 errored for me after I posted. I therefore deleted it as you have now discovered. If you can post back what you have been using I can check it against my many different versions and provided it doesn’t error again I can return the original.

Sub ANNOTATE_EVENTS_1()
	Dim oDoc As Object: oDoc = ThisComponent
	Dim oSheet10 As Object: oSheet10 = oDoc.getSheets().getByName("FX_Rates_2")
	Dim oSheet12 As Object: oSheet12 = oDoc.getSheets().getByName("Gold_Fixes_1")
	Dim oDrawPage As Object: oDrawPage = oSheet12.getDrawPage()
	Dim oCellRange As Object
	Dim sCellValue As String
	Dim vDataArray As Variant
	Dim nCount As Long, i As Long, x As Long, y As Long
	Dim lrB10 As Long, lrB12 As Long
	Dim vNoteableEvents() As Long
	Dim sEventText As String
	
	''create NoteableEvents Array
	With oSheet10 ''FX_Rates_2
		lrB10 = Get_Last_Row_With_Data(1, 10)    
		oCellRange = .getCellRangeByPosition(58, 12, 58, lrB10-1)
		vDataArray = oCellRange.getDataArray()
		nCount = 0
		For i = LBound(vDataArray) To UBound(vDataArray)
			sCellValue = Trim(CStr(vDataArray(i)(0)))
			If sCellValue <> "" Then
				ReDim Preserve vNoteableEvents(nCount)
				vNoteableEvents(nCount) = i
				nCount = nCount + 1  
			End If
		Next i
	End With
	
	''exit if no recorded events
	If nCount = 0 Then
		MsgBox "There are no events recorded in this period."
		Exit Sub
	End If
	
	''extract chart visual dimensions
	Dim oChartsContainer As Object
	Dim oChartWrapper As Object
	Dim oChartShape As Object
	Dim chartLeft As Long, chartTop As Long, chartWidth As Long, chartHeight As Long
	Dim totalRowsCount As Long
	
	oChartsContainer = oSheet12.getCharts()
	oChartWrapper = oChartsContainer.getByIndex(0)
	totalRowsCount = lrb10-12
	
	''get primary OLE2 chart shape properties
	For i = 0 To oDrawPage.getCount() - 1
		oChartShape = oDrawPage.getByIndex(i)
		If oChartShape.supportsService("com.sun.star.drawing.OLE2Shape") Then
			chartLeft = oChartShape.getPosition().X
			chartTop = oChartShape.getPosition().Y
			chartWidth = oChartShape.getSize().Width
			chartHeight = oChartShape.getSize().Height
			Exit For
		End If
	Next i
	
	''push chart to background plane so new lines draw in front of it
	oChartShape.ZOrder = 0
	
	''establish explicit mathematical bounds of inner data graph grid frame
	Dim plotLeft As Long, plotTop As Long, plotWidth As Long, plotHeight As Long	
	plotLeft = chartLeft + (chartWidth * 0.11)
	plotTop = chartTop + (chartHeight * 0.09)
	plotWidth = chartWidth * 0.81
	plotHeight = chartHeight * 0.72
	
	''persistent layout trackers allocated outside loop passes
	Dim aLinePoints(1) As New com.sun.star.awt.Point
	Dim oShapePos As New com.sun.star.awt.Point
	Dim oShapeSize As New com.sun.star.awt.Size
	Dim oLeaderLine As Object
	Dim oTextShape As Object
	Dim targetPointX As Long, targetPointY As Long
	
	''systemic multi-stack collision lookback engine trackers
	Dim aAllocatedX() As Long
	Dim aAllocatedYOffsets() As Long
	Dim nAllocatedCount As Long
	nAllocatedCount = 0
	
	''remove previous datalabels and leaderlines
	Dim oShape As Object
		For x = oDrawPage.getCount() - 1 To 0 Step -1
		oShape = oDrawPage.getByIndex(x)
		If oShape.Name = "" Then
			oDrawPage.remove(oShape)
		End if
	Next x
	
	''process individual annotations	
	For x = 0 To UBound(vNoteableEvents)
		sEventText = Trim(CStr(vDataArray(vNoteableEvents(x))(0)))
		
		'calculate precise locations based purely on chronological cell sequence,
		'completely removing the 18-month offset drift and reverse order bugs.
		targetPointX = plotLeft + CLng((vNoteableEvents(x) / totalRowsCount) * plotWidth)
		'drop target point directly to midline profile height of your graph layout
		targetPointY = plotTop + (plotHeight / 1)	''originally (plotHeight / 2)
		
		'pixel proximity multi-stack engine lookback
		Dim currentYOffset As Long
		currentYOffset = 1000 '' originall 500 = base clearance depth below data nodes
		
		For y = 0 To nAllocatedCount - 1
			If Abs(targetPointX - aAllocatedX(y)) < 3600 Then
				If aAllocatedYOffsets(y) >= currentYOffset Then
					currentYOffset = aAllocatedYOffsets(y) - 600 ' Stack up by 6.0mm steps
				End If
			End If
		Next y
		
		ReDim Preserve aAllocatedX(nAllocatedCount)
		ReDim Preserve aAllocatedYOffsets(nAllocatedCount)
		aAllocatedX(nAllocatedCount) = targetPointX
		aAllocatedYOffsets(nAllocatedCount) = currentYOffset
		nAllocatedCount = nAllocatedCount + 1
		
		''build canvas graphics segment layer
		''create and position leaderline
		oLeaderLine = oDoc.createInstance("com.sun.star.drawing.PolyLineShape")
		oDrawPage.add(oLeaderLine)
		ReDim aLinePoints(1) As New com.sun.star.awt.Point
		aLinePoints(0).X = targetPointX
		aLinePoints(0).Y = targetPointY
		aLinePoints(1).X = targetPointX
		aLinePoints(1).Y = targetPointY + currentYOffset
		oLeaderLine.Polygon = aLinePoints()
		
		''format leaderline
		With oLeaderLine
			.LineColor = RGB(255, 0, 255) ''Magenta
			.LineWidth = 80               ''0.2 mm fine-line weight path
			.LineStyle = com.sun.star.drawing.LineStyle.SOLID
			.LineTransparence = 70
		End With
		
		''move leaderline to foreground layer
		oLeaderLine.ZOrder = 10 + x
		
		''add custom datalabel
		oTextShape = oDoc.createInstance("com.sun.star.drawing.TextShape")
		oDrawPage.add(oTextShape)
		
		''fix shape dimensions, autosize width after adding string
		oShapeSize.Width = 3500       ' 35 mm box width
		oShapeSize.Height = 450       ' 4.5 mm text height
		oTextShape.setSize(oShapeSize)
		
		oShapePos.X = targetPointX - (oShapeSize.Width / 2)
		oShapePos.Y = targetPointY + currentYOffset
		oTextShape.setPosition(oShapePos)
		
		oTextShape.setString(sEventText)
		
		With oTextShape
			.CharFontName = "Liberation Mono"
			.CharHeight = 10
			''force autosize width
			.TextAutoGrowWidth = True
			.TextAutoGrowHeight = True
			'.CharWeight = com.sun.star.awt.FontWeight.MEDIUM Fails here there's no  constant MEDIUM
			.CharWeight = com.sun.star.awt.FontWeight.SEMIBOLD ''SEMIBOLD and BOLD
			.CharColor = RGB(0,0,0)
			.TextHorizontalAdjust = com.sun.star.drawing.TextHorizontalAdjust.CENTER
			.FillStyle = com.sun.star.drawing.FillStyle.SOLID
			.FillColor = RGB(255,255,255) ' Solid white background blocks chart grids
			.FillTransparence = 50
		End With
		
		''move text box shape to absolute foreground layer
		oTextShape.ZOrder = 100 + x
	Next x
	
	Erase vNoteableEvents
	Erase aAllocatedX
	Erase aAllocatedYOffsets
	MsgBox "Done"
End Sub

I executed the code above and the this:

Sub FixChartWithAutomaticCropping()

    Dim oSheet As Object
    Dim oCellCursor As Object
    Dim nLastRow As Long
    Dim oLastCell As Object
    Dim dLastDate As Double
    
    Dim oCharts As Object
    Dim oChartObject As Object
    Dim oChart As Object
    Dim oDiagram As Object
    Dim oXAxis As Object
    
    ' 1. Get the active sheet
    oSheet = ThisComponent.CurrentController.ActiveSheet
    
    ' 2. AUTOMATICALLY FINDING THE LAST CELL CONTAINING DATA.
    ' Creating a "cursor" that extends to the end of the data.
    oCellCursor = oSheet.createCursor()
    oCellCursor.gotoEndofUsedArea(False)
    nLastRow = oCellCursor.getRangeAddress().EndRow
    
    ' Retrieve the value (date serial number) from the last row of column A (index 0).
    ' If your dates are in column B, for example, change 0 to 1 (and so on).
    oLastCell = oSheet.getCellByPosition(0, nLastRow)
    
    ' Ensuring that the cell contains a number or a date.
    If oLastCell.Type = com.sun.star.table.CellContentType.VALUE Then
        dLastDate = oLastCell.Value
    Else
        MsgBox "No valid date was found in the last cell.", 48, "Error"
        Exit Sub 'fails here
    End If
    
    ' 3. RETRIEVING THE FIRST CHART FROM THE SHEET
    oCharts = oSheet.getCharts()
    If Not oCharts.hasElements() Then
        MsgBox "No charts were found on the worksheet.", 48, "Error"
        Exit Sub
    End If
    
    oChartObject = oCharts.getByIndex(0)
    oChart = oChartObject.getEmbeddedObject()
    
    ' 4. Forcing the chart type to an XY scatter plot.
    ' This moves the detached text and vertical lines back to their correct positions.
    oDiagram = oChart.createInstance("com.sun.star.chart.XYDiagram")
    oChart.setDiagram(oDiagram)
    
    ' Retrieving a new chart object for the layout.
    oDiagram = oChart.getFirstDiagram()
    
    ' 5. AUTOMATICALLY DETERMING MAXIMUM VALUE OF THE X-AXIS
    oXAxis = oDiagram.XAxis
    oXAxis.AutoMax = False
    oXAxis.Max = dLastDate ' The last date found in the sheet is set as the limit.
    
    ' Updating the chart
    oChart.modified()
    
    ' Converting the serial number to text for the notification
    Dim sDateText As String
    sDateText = CStr(CDate(dLastDate))
    
    MsgBox "Chart corrected! X-axis automatically limited to the latest data: " & sDateText, 64, "Done"

End Sub

Then removed all code, but added this:

Sub OnClose 'bound to document closing event (Tools > Customize >:Events tab)
    ThisComponent.setModified(False)
End Sub

and saved the project

CHART_ANNOTATION v2.ods (586.8 KB)

In Finnish lang environment the diagram now looks like this:

Yes, I have got the same result from ANNOTATE_EVENTS_1 with a number of versions however the chart shows the first label approx 7 months late and the last label approx 7 months early.
‘EU ref’ is listed as 23rd June 2016 and it appears after Dec 2016. Similarly ‘Iranian conflict’ is listed at 27th February 2026 but is shown on the chart as approx July 2025. The X axis is somehow getting squashed.

Manually drag the leader lines and labels to the correct positions using the mouse and save your project. Make some copies (maybe a .zip too) and continue forward to find the correct code :slightly_smiling_face:

Checking out the additional code it has uncovered issues with my calculating and formatting of weekday only dates in column B. I will have to get that sorted before going further.
However I bypassed the CellContentType check but it erors at oXAxis = oDiagram.XAxis with property or method not found XAxis.

It’s tricky, this is the playground

Dim oCoordSys As Object
Dim oXAxis As Object

’ Retrieving the first coordinate system.
oCoordSys = oDiagram.getCoordinateSystems()(0)

’ Retrieving the X-axis (0 = X-axis, 1 = Y-axis, 2 = Z-axis)
oXAxis = oCoordSys.getAxisByDimension(0, 0)

Dim oCoords As Object
Dim oCoordSys As Object
Dim oXAxis As Object

’ 1. Retrieving the chart’s coordinate systems (returns an array)
oCoords = oDiagram.getCoordinateSystems()

’ 2. Implementation of the first coordinate system.
oCoordSys = oCoords(0)

’ 3. Retrieving the X-axis (first zero = X-axis, second zero = principal axis).
oXAxis = oCoordSys.getAxisByDimension(0, 0)

Dim oScaleData As Object

’ 1. Retrieving the axis’s current scaling structure.
oScaleData = oXAxis.getScaleData()

’ 2. Setting a fixed maximum value (this automatically removes the “Automatic” selection).
oScaleData.Maximum = 100 ’ Replace the number with your desired maximum value.

’ 3.If necessary, you can also adjust the minimum and the interval in the same way:
’ oScaleData.Minimum = 0
’ oScaleData.StepMain = 10

’ 4. Saving the modified settings back to the axis.
oXAxis.setScaleData(oScaleData)

You can do it like this:

oXAxis = oChartDoc.Diagram.XAxis 

Blockquote
You can do it like this:

oXAxis = oChartDoc.Diagram.XAxis 

Making a direct substitution did not run for me. But clearly there is much to learn re LO’s chart terminology.

Here is a modified fragment of your macro (ANNOTATE_EVENTS_2):

    ' 2. DRILL DOWN INTO TARGET CHART LAYERS ON ACTIVE SHEET
    oActiveSheet = oDoc.CurrentController.ActiveSheet
    oCharts = oActiveSheet.getCharts()
    
    If oCharts.getCount() = 0 Then
        MsgBox "No target chart found on the active sheet.", 48, "Error"
        Exit Sub
    End If
    oChart = oCharts.getByIndex(0)
    oChartDoc = oChart.getEmbeddedObject()
    oDiagram = oChartDoc.getFirstDiagram()

    Dim oXAxis
    oXAxis = oChartDoc.Diagram.XAxis 
    Msgbox  "Min: " & Cdate(oXAxis.Min) & " Max: " & Cdate(oXAxis.Max)

2026-07-09 192833